diff --git a/Sources/CodexBarCLI/CLICostCommand.swift b/Sources/CodexBarCLI/CLICostCommand.swift index 9faa15b057..c6c0df9f7e 100644 --- a/Sources/CodexBarCLI/CLICostCommand.swift +++ b/Sources/CodexBarCLI/CLICostCommand.swift @@ -47,16 +47,17 @@ extension CodexBarCLI { cursorCookieSettingsError = error } let groupBy = Self.decodeCostGroupBy(from: values) - if groupBy == .project { - // Provider-specific by design: only Codex JSONL sessions carry the local project attribution index. - let unsupportedProjectProviders = providers.filter { $0 != .codex } - if !unsupportedProjectProviders.isEmpty, !output.jsonOnly { - let names = unsupportedProjectProviders - .map { ProviderDescriptorRegistry.descriptor(for: $0).metadata.displayName } - .sorted() - .joined(separator: ", ") - Self.writeStderr("Skipping project grouping for providers without Codex project data: \(names)\n") - } + Self.warnSkippedGroupingProviders(groupBy: groupBy, providers: providers, jsonOnly: output.jsonOnly) + // Provider-specific by design: this warning applies only when Codex is among the requested providers. + if providers.contains(.codex), + !output.jsonOnly, + let warning = Self.sessionGroupingPiOmissionWarning( + provider: .codex, + groupBy: groupBy, + format: format, + includePiSessions: includePiSessions) + { + Self.writeStderr("Warning: \(warning)\n") } let fetcher = CostUsageFetcher() @@ -64,8 +65,8 @@ extension CodexBarCLI { var payload: [CostPayload] = [] var exitCode: ExitCode = .success - // Provider-specific by design: project grouping is available only for Codex local session data. - for provider in providers where groupBy != .project || provider == .codex || format == .json { + // Provider-specific by design: project/session grouping is available only for Codex local session data. + for provider in Self.costProviders(providers, groupBy: groupBy, format: format) { if let error = Self.cursorCostAvailabilityError( provider, settings: cursorCookieSettings, @@ -88,7 +89,11 @@ extension CodexBarCLI { historyDays: historyDays, cursorCookieHeaderOverride: Self.cursorCostHeaderOverride(provider, settings: cursorCookieSettings), refreshPricingInBackground: false, - includePiSessions: includePiSessions) + includePiSessions: Self.costIncludePiSessions( + provider: provider, + groupBy: groupBy, + format: format, + includePiSessions: includePiSessions)) switch format { case .text: sections.append(Self.renderCostText( @@ -126,6 +131,15 @@ extension CodexBarCLI { enum CostGroupBy: String { case none case project + case session + + /// Groupings that depend on Codex-local session data and only apply to text output. + var requiresCodexLocalSessions: Bool { + switch self { + case .none: false + case .project, .session: true + } + } } static func renderCostText( @@ -143,6 +157,9 @@ extension CodexBarCLI { if groupBy == .project, provider == .codex { return Self.renderProjectCostText(header: header, snapshot: snapshot) } + if groupBy == .session, provider == .codex { + return Self.renderSessionCostText(header: header, snapshot: snapshot) + } let todayCost = snapshot.sessionCostUSD .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" @@ -204,6 +221,67 @@ extension CodexBarCLI { return lines.joined(separator: "\n") } + private static func renderSessionCostText(header: String, snapshot: CostUsageTokenSnapshot) -> String { + let historyLabel = snapshot.historyLabel + ?? (snapshot.historyDays == 1 ? "Today" : "Last \(snapshot.historyDays) days") + var lines = [header, "Conversations (\(historyLabel)):"] + let historyIncomplete = snapshot.historyCoverageIsEstablished == false + if historyIncomplete { + lines.append("Conversation history is incomplete while the local scan catches up.") + } + guard !snapshot.sessions.isEmpty else { + if !historyIncomplete { + lines.append("—") + } + // Provider-specific by design: session output uses Codex's local estimate hint. + lines.append(Self.costEstimateHint(provider: .codex)) + return lines.joined(separator: "\n") + } + for session in snapshot.sessions { + let cost = session.costUSD + .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" + var summary = [cost] + if let tokens = session.totalTokens { + summary.append("\(UsageFormatter.tokenCountString(tokens)) tokens") + } + if let requests = session.requestCount { + summary.append("\(requests) requests") + } + lines.append("Session \(Self.shortSessionID(session.sessionID)): \(summary.joined(separator: " · "))") + let modelLabel = Self.sessionModelLabel(session.modelBreakdowns.map(\.modelName)) + lines.append("\(modelLabel) · \(Self.sessionTimestampString(session.lastActivity))") + } + // Provider-specific by design: session output uses Codex's local estimate hint. + lines.append(Self.costEstimateHint(provider: .codex)) + return lines.joined(separator: "\n") + } + + /// Privacy-conscious shortened session identifier, matching the macOS cost-history UI convention. + 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))" + } + + static func sessionModelLabel(_ models: [String]) -> String { + let labels = models.map(UsageFormatter.modelDisplayName) + return if labels.isEmpty { + "Unknown model" + } else if labels.count == 1 { + labels[0] + } else { + "\(labels[0]) +\(labels.count - 1) model\(labels.count > 2 ? "s" : "")" + } + } + + private static func sessionTimestampString(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = .current + formatter.dateFormat = "MMM d, HH:mm" + return formatter.string(from: date) + } + private static func costEstimateHint(provider: UsageProvider) -> String { provider == .codex ? "Not a subscription bill or plan value · local usage × public API prices" @@ -219,6 +297,69 @@ extension CodexBarCLI { selection.asList.filter { Self.costSupportedProviders.contains($0) } } + /// Providers participating in a cost run: text-mode project/session grouping is Codex-only, + /// while JSON output always keeps every requested provider. + static func costProviders( + _ providers: [UsageProvider], + groupBy: CostGroupBy, + format: OutputFormat) -> [UsageProvider] + { + // Provider-specific by design: text grouping relies on Codex local indexes while JSON preserves all providers. + providers.filter { !groupBy.requiresCodexLocalSessions || $0 == .codex || format == .json } + } + + /// Session text reports need native Codex rows, so keep Pi/OMP aggregate merging out of that path. + static func costIncludePiSessions( + provider: UsageProvider, + groupBy: CostGroupBy, + format: OutputFormat, + includePiSessions: Bool) -> Bool + { + // Provider-specific by design: only Codex local session text bypasses Pi/OMP merging. + guard provider == .codex, groupBy == .session, format == .text else { return includePiSessions } + return false + } + + static func sessionGroupingPiOmissionWarning( + provider: UsageProvider, + groupBy: CostGroupBy, + format: OutputFormat, + includePiSessions: Bool) -> String? + { + // Provider-specific by design: only Codex local session text warns about omitted mirrors. + guard provider == .codex, + groupBy == .session, + format == .text, + includePiSessions + else { return nil } + return "Session grouping shows native Codex conversations only; Pi/OMP usage is omitted from this view. " + + "Use the default cost view for merged totals." + } + + /// Provider-specific by design: only Codex JSONL sessions carry the local project/session indexes, + /// so text-mode grouping skips other providers with a concise stderr notice. + static func warnSkippedGroupingProviders( + groupBy: CostGroupBy, + providers: [UsageProvider], + jsonOnly: Bool) + { + guard !jsonOnly else { return } + let unsupported = providers.filter { $0 != .codex } + guard !unsupported.isEmpty else { return } + let names = unsupported + .map { ProviderDescriptorRegistry.descriptor(for: $0).metadata.displayName } + .sorted() + .joined(separator: ", ") + switch groupBy { + case .project: + Self.writeStderr("Skipping project grouping for providers without Codex project data: \(names)\n") + case .session: + Self.writeStderr("Skipping session grouping for providers without Codex session data: \(names)\n") + case .none: + break + } + } + static func makeCostPayload( provider: UsageProvider, snapshot: CostUsageTokenSnapshot?, @@ -361,7 +502,7 @@ extension CodexBarCLI { !values.flags.contains("providerNativeOnly") } - private static func decodeCostGroupBy(from values: ParsedValues) -> CostGroupBy { + static func decodeCostGroupBy(from values: ParsedValues) -> CostGroupBy { guard let raw = values.options["groupBy"]?.last?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return .none } @@ -477,7 +618,7 @@ struct CostOptions: CommanderParsable { @Option(name: .long("days"), help: "Cost history window in days (1...365)") var days: Int? - @Option(name: .long("group-by"), help: "Group text output by: project") + @Option(name: .long("group-by"), help: "Group text output by: project | session") var groupBy: String? } diff --git a/Sources/CodexBarCLI/CLIHelp.swift b/Sources/CodexBarCLI/CLIHelp.swift index 3399ea8374..6ff64710c8 100644 --- a/Sources/CodexBarCLI/CLIHelp.swift +++ b/Sources/CodexBarCLI/CLIHelp.swift @@ -116,7 +116,7 @@ extension CodexBarCLI { [--json-output] [--log-level ] [-v|--verbose] [--provider \(ProviderHelp.list)] [--no-color] [--pretty] [--refresh] [--provider-native-only] - [--days ] [--group-by project] + [--days ] [--group-by project|session] Description: Print local token cost usage from Claude/Codex native logs plus supported pi and OMP sessions. @@ -126,6 +126,7 @@ extension CodexBarCLI { Examples: codexbar cost codexbar cost --provider codex --group-by project + codexbar cost --provider codex --group-by session codexbar cost --provider claude --format json --pretty """ } @@ -458,7 +459,7 @@ extension CodexBarCLI { [--json-output] [--log-level ] [-v|--verbose] [--provider \(ProviderHelp.list)] [--no-color] [--pretty] [--refresh] [--provider-native-only] - [--days ] [--group-by project] + [--days ] [--group-by project|session] codexbar sessions [--json|--json-v2] [--pretty] codexbar sessions focus codexbar dashboard [--pretty] [--timeout ] [--output ] diff --git a/Sources/CodexBarCLI/CLIHelpers.swift b/Sources/CodexBarCLI/CLIHelpers.swift index 80a95ce3b8..96e50a4166 100644 --- a/Sources/CodexBarCLI/CLIHelpers.swift +++ b/Sources/CodexBarCLI/CLIHelpers.swift @@ -420,6 +420,10 @@ extension CodexBarCLI { self.decodeFormat(from: values) } + static func _decodeCostGroupByForTesting(from values: ParsedValues) -> CostGroupBy { + self.decodeCostGroupBy(from: values) + } + static func _decodeWebTimeoutForTesting(from values: ParsedValues) throws -> TimeInterval? { try self.decodeWebTimeout(from: values) } diff --git a/Tests/CodexBarTests/CLICostTests.swift b/Tests/CodexBarTests/CLICostTests.swift index f2c2c0370e..7e2c6df386 100644 --- a/Tests/CodexBarTests/CLICostTests.swift +++ b/Tests/CodexBarTests/CLICostTests.swift @@ -27,6 +27,383 @@ struct CLICostTests { #expect(!CodexBarCLI.decodeCostIncludePiSessions(from: nativeOnlyValues)) } + @Test + func `parses session group by and keeps project parsing`() throws { + let parser = CommandParser(signature: CodexBarCLI._costSignatureForTesting()) + + let sessionValues = try parser.parse(arguments: ["--group-by", "session"]) + #expect(CodexBarCLI._decodeCostGroupByForTesting(from: sessionValues) == .session) + + let projectValues = try parser.parse(arguments: ["--group-by", "project"]) + #expect(CodexBarCLI._decodeCostGroupByForTesting(from: projectValues) == .project) + + let defaultValues = try parser.parse(arguments: []) + #expect(CodexBarCLI._decodeCostGroupByForTesting(from: defaultValues) == .none) + } + + @Test + func `session grouping is codex only in text mode`() { + let textProviders = CodexBarCLI.costProviders( + [.claude, .codex], + groupBy: .session, + format: .text) + #expect(textProviders.map(\.rawValue) == ["codex"]) + + let jsonProviders = CodexBarCLI.costProviders( + [.claude, .codex], + groupBy: .session, + format: .json) + #expect(jsonProviders.map(\.rawValue) == ["claude", "codex"]) + } + + @Test + func `session text grouping disables pi merge only for codex`() { + #expect(CodexBarCLI.costIncludePiSessions( + provider: .codex, + groupBy: .session, + format: .text, + includePiSessions: true) == false) + #expect(CodexBarCLI.costIncludePiSessions( + provider: .codex, + groupBy: .session, + format: .json, + includePiSessions: true)) + #expect(CodexBarCLI.costIncludePiSessions( + provider: .codex, + groupBy: .project, + format: .text, + includePiSessions: true)) + #expect(CodexBarCLI.costIncludePiSessions( + provider: .claude, + groupBy: .session, + format: .text, + includePiSessions: true)) + } + + @Test + func `session grouping warns when default pi usage is omitted`() { + #expect(CodexBarCLI.sessionGroupingPiOmissionWarning( + provider: .codex, + groupBy: .session, + format: .text, + includePiSessions: true)?.contains("Pi/OMP usage is omitted") == true) + #expect(CodexBarCLI.sessionGroupingPiOmissionWarning( + provider: .codex, + groupBy: .session, + format: .text, + includePiSessions: false) == nil) + #expect(CodexBarCLI.sessionGroupingPiOmissionWarning( + provider: .codex, + groupBy: .session, + format: .json, + includePiSessions: true) == nil) + #expect(CodexBarCLI.sessionGroupingPiOmissionWarning( + provider: .claude, + groupBy: .session, + format: .text, + includePiSessions: true) == nil) + } + + @Test + func `session grouping falls back for unsupported providers`() { + let snap = CostUsageTokenSnapshot( + sessionTokens: 1200, + sessionCostUSD: 1.25, + last30DaysTokens: 9000, + last30DaysCostUSD: 9.99, + historyDays: 30, + daily: [], + updatedAt: Date(timeIntervalSince1970: 0)) + let output = CodexBarCLI.renderCostText(provider: .claude, snapshot: snap, groupBy: .session, useColor: false) + + #expect(output.contains("Claude Cost (API-rate estimate)")) + #expect(!output.contains("Conversations (")) + } + + @Test + func `renders codex session grouped cost text`() { + let sessionDate = Date(timeIntervalSince1970: 1_750_000_000) + let snap = CostUsageTokenSnapshot( + sessionTokens: 1200, + sessionCostUSD: 1.25, + last30DaysTokens: 9000, + last30DaysCostUSD: 9.99, + historyDays: 30, + daily: [], + sessions: [ + CostUsageSessionBreakdown( + sessionID: "abcd12345678abcd12345678abcd12345678", + lastActivity: sessionDate, + inputTokens: 1_600_000, + cachedInputTokens: 200_000, + outputTokens: 200_000, + totalTokens: 1_800_000, + requestCount: 37, + costUSD: 4.21, + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.4", + costUSD: 4.21, + totalTokens: 1_800_000), + ]), + ], + updatedAt: Date(timeIntervalSince1970: 0)) + let output = CodexBarCLI.renderCostText(provider: .codex, snapshot: snap, groupBy: .session, useColor: false) + .replacingOccurrences(of: "\u{00A0}", with: " ") + .replacingOccurrences(of: "$ ", with: "$") + + #expect(output.contains("Codex API-equivalent estimate (not billed)")) + #expect(output.contains("Conversations (Last 30 days):")) + #expect(output.contains("Session abcd...12345678: $4.21 · 1.8M tokens · 37 requests")) + #expect(output.contains("gpt-5.4 · \(sessionTimestamp(sessionDate))")) + #expect(output.contains("Not a subscription bill or plan value · local usage × public API prices")) + } + + @Test + func `session rows preserve snapshot ordering`() throws { + let snap = CostUsageTokenSnapshot( + sessionTokens: 1200, + sessionCostUSD: 1.25, + last30DaysTokens: 9000, + last30DaysCostUSD: 9.99, + historyDays: 30, + daily: [], + sessions: [ + CostUsageSessionBreakdown( + sessionID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + lastActivity: Date(timeIntervalSince1970: 1_750_000_000), + inputTokens: nil, + cachedInputTokens: nil, + outputTokens: nil, + totalTokens: 100, + requestCount: nil, + costUSD: 1.0, + modelBreakdowns: []), + CostUsageSessionBreakdown( + sessionID: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + lastActivity: Date(timeIntervalSince1970: 1_751_000_000), + inputTokens: nil, + cachedInputTokens: nil, + outputTokens: nil, + totalTokens: 200, + requestCount: nil, + costUSD: 2.0, + modelBreakdowns: []), + ], + updatedAt: Date(timeIntervalSince1970: 0)) + let output = CodexBarCLI.renderCostText(provider: .codex, snapshot: snap, groupBy: .session, useColor: false) + + let first = try #require(output.range(of: "Session aaaa...aaaaaaaa")) + let second = try #require(output.range(of: "Session bbbb...bbbbbbbb")) + #expect(first.lowerBound < second.lowerBound) + } + + @Test + func `session with unknown cost renders dash not zero`() { + let snap = CostUsageTokenSnapshot( + sessionTokens: 1200, + sessionCostUSD: nil, + last30DaysTokens: 9000, + last30DaysCostUSD: nil, + historyDays: 30, + daily: [], + sessions: [ + CostUsageSessionBreakdown( + sessionID: "abcd12345678abcd12345678abcd12345678", + lastActivity: Date(timeIntervalSince1970: 1_750_000_000), + inputTokens: 800_000, + cachedInputTokens: nil, + outputTokens: 20000, + totalTokens: 820_000, + requestCount: 19, + costUSD: nil, + modelBreakdowns: []), + ], + updatedAt: Date(timeIntervalSince1970: 0)) + let output = CodexBarCLI.renderCostText(provider: .codex, snapshot: snap, groupBy: .session, useColor: false) + .replacingOccurrences(of: "\u{00A0}", with: " ") + .replacingOccurrences(of: "$ ", with: "$") + + #expect(output.contains("Session abcd...12345678: — · 820K tokens · 19 requests")) + #expect(!output.contains("$0")) + #expect(output.contains("Unknown model · ")) + } + + @Test + func `session with partial data renders compactly`() { + let snap = CostUsageTokenSnapshot( + sessionTokens: 1200, + sessionCostUSD: 1.25, + last30DaysTokens: 9000, + last30DaysCostUSD: 9.99, + historyDays: 30, + daily: [], + sessions: [ + CostUsageSessionBreakdown( + sessionID: "efgh87654321efgh87654321efgh87654321", + lastActivity: Date(timeIntervalSince1970: 1_750_000_000), + inputTokens: nil, + cachedInputTokens: nil, + outputTokens: nil, + totalTokens: nil, + requestCount: nil, + costUSD: 2.17, + modelBreakdowns: []), + ], + updatedAt: Date(timeIntervalSince1970: 0)) + let output = CodexBarCLI.renderCostText(provider: .codex, snapshot: snap, groupBy: .session, useColor: false) + .replacingOccurrences(of: "\u{00A0}", with: " ") + .replacingOccurrences(of: "$ ", with: "$") + + #expect(output.contains("Session efgh...87654321: $2.17")) + #expect(!output.contains("tokens")) + #expect(!output.contains("requests")) + #expect(output.contains("Unknown model · ")) + } + + @Test + func `session model label stays compact for multiple models`() { + let sessions = [ + CostUsageSessionBreakdown( + sessionID: "abcd12345678abcd12345678abcd12345678", + lastActivity: Date(timeIntervalSince1970: 1_750_000_000), + inputTokens: nil, + cachedInputTokens: nil, + outputTokens: nil, + totalTokens: 100, + requestCount: nil, + costUSD: 1.0, + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown(modelName: "gpt-5.4", costUSD: 0.5, totalTokens: 50), + CostUsageDailyReport.ModelBreakdown(modelName: "gpt-5.2-codex", costUSD: 0.5, totalTokens: 50), + ]), + CostUsageSessionBreakdown( + sessionID: "efgh87654321efgh87654321efgh87654321", + lastActivity: Date(timeIntervalSince1970: 1_750_000_000), + inputTokens: nil, + cachedInputTokens: nil, + outputTokens: nil, + totalTokens: 150, + requestCount: nil, + costUSD: 1.5, + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown(modelName: "gpt-5.4", costUSD: 0.5, totalTokens: 50), + CostUsageDailyReport.ModelBreakdown(modelName: "gpt-5.2-codex", costUSD: 0.5, totalTokens: 50), + CostUsageDailyReport.ModelBreakdown( + modelName: "fictitious-model-alpha", + costUSD: 0.5, + totalTokens: 50), + ]), + ] + let snap = CostUsageTokenSnapshot( + sessionTokens: 1200, + sessionCostUSD: 1.25, + last30DaysTokens: 9000, + last30DaysCostUSD: 9.99, + historyDays: 30, + daily: [], + sessions: sessions, + updatedAt: Date(timeIntervalSince1970: 0)) + let output = CodexBarCLI.renderCostText(provider: .codex, snapshot: snap, groupBy: .session, useColor: false) + + #expect(output.contains("gpt-5.4 +1 model · ")) + #expect(output.contains("gpt-5.4 +2 models · ")) + #expect(!output.contains("fictitious-model-alpha · ")) + } + + @Test + func `session grouping with no sessions renders empty state`() { + let snap = CostUsageTokenSnapshot( + sessionTokens: 1200, + sessionCostUSD: 1.25, + last30DaysTokens: 9000, + last30DaysCostUSD: 9.99, + historyDays: 30, + daily: [], + updatedAt: Date(timeIntervalSince1970: 0)) + let output = CodexBarCLI.renderCostText(provider: .codex, snapshot: snap, groupBy: .session, useColor: false) + + #expect(output.contains("Conversations (Last 30 days):\n—\n")) + #expect(output.contains("Not a subscription bill or plan value · local usage × public API prices")) + } + + @Test + func `session grouping labels incomplete history during catch up`() { + let snap = CostUsageTokenSnapshot( + sessionTokens: 1200, + sessionCostUSD: 1.25, + last30DaysTokens: 9000, + last30DaysCostUSD: 9.99, + historyDays: 30, + historyCoverageIsEstablished: false, + daily: [], + updatedAt: Date(timeIntervalSince1970: 0)) + let output = CodexBarCLI.renderCostText(provider: .codex, snapshot: snap, groupBy: .session, useColor: false) + + #expect(output.contains("Conversation history is incomplete while the local scan catches up.")) + #expect(!output.contains("Conversations (Last 30 days):\n—\n")) + } + + @Test + func `session grouping labels partial history during catch up`() { + let snap = CostUsageTokenSnapshot( + sessionTokens: 1200, + sessionCostUSD: 1.25, + last30DaysTokens: 9000, + last30DaysCostUSD: 9.99, + historyDays: 30, + historyCoverageIsEstablished: false, + daily: [], + sessions: [ + CostUsageSessionBreakdown( + sessionID: "abcd12345678abcd12345678abcd12345678", + lastActivity: Date(timeIntervalSince1970: 1_750_000_000), + inputTokens: nil, + cachedInputTokens: nil, + outputTokens: nil, + totalTokens: 100, + requestCount: 2, + costUSD: 0.04, + modelBreakdowns: []), + ], + updatedAt: Date(timeIntervalSince1970: 0)) + let output = CodexBarCLI.renderCostText(provider: .codex, snapshot: snap, groupBy: .session, useColor: false) + + #expect(output.contains("Conversation history is incomplete while the local scan catches up.")) + #expect(output.contains("Session abcd...12345678")) + } + + @Test + func `session grouping does not change cost JSON payload`() throws { + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 10, + sessionCostUSD: 0.01, + last30DaysTokens: 40, + last30DaysCostUSD: 0.04, + daily: [], + sessions: [ + CostUsageSessionBreakdown( + sessionID: "abcd12345678abcd12345678abcd12345678", + lastActivity: Date(timeIntervalSince1970: 1_750_000_000), + inputTokens: 30, + cachedInputTokens: nil, + outputTokens: 10, + totalTokens: 40, + requestCount: 2, + costUSD: 0.04, + modelBreakdowns: []), + ], + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + let payload = CodexBarCLI.makeCostPayload(provider: .codex, snapshot: snapshot, error: nil) + let data = try JSONEncoder().encode(payload) + guard let json = String(data: data, encoding: .utf8) else { + Issue.record("Failed to decode cost payload JSON") + return + } + + #expect(!json.contains("\"sessions\"")) + } + @Test func `renders cost text snapshot`() { let snap = CostUsageTokenSnapshot( @@ -374,6 +751,14 @@ struct CLICostTests { } } +private func sessionTimestamp(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = .current + formatter.dateFormat = "MMM d, HH:mm" + return formatter.string(from: date) +} + private struct CursorCostSettingsTestError: LocalizedError { var errorDescription: String? { "Cursor settings resolution failed." diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index 014fceaf06..17ef35cc01 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -4,6 +4,61 @@ import Testing @Suite(.serialized) struct CostUsageFetcherTests { + @Test + func `native codex sessions survive when pi usage is present but pi merge is disabled`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: day, + filename: "native.jsonl", + tokens: 100) + _ = try env.writePiSessionFile( + relativePath: "2026-04-08T10-00-00-000Z_mixed.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], + ], + ]])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + let piOptions = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) + + let merged = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + historyDays: 1, + includePiSessions: true, + scannerOptions: options, + piScannerOptions: piOptions) + let nativeOnly = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day.addingTimeInterval(1), + historyDays: 1, + includePiSessions: false, + scannerOptions: options, + piScannerOptions: piOptions) + + #expect(merged.sessions.isEmpty) + #expect(nativeOnly.sessionTokens == 100) + #expect(nativeOnly.sessions.count == 1) + } + @Test func `fetcher scopes codex history to selected codex home`() async throws { let env = try CostUsageTestEnvironment() diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index c9ca73a5ce..a4c9daf7eb 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1311,25 +1311,25 @@ struct ProviderArchitectureGatekeeperTests { reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 180, + line: 197, anchor: "lines.append(Self.costEstimateHint(provider: .codex))", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 203, + line: 220, anchor: "lines.append(Self.costEstimateHint(provider: .codex))", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 390, + line: 531, anchor: "let account = try context.resolvedAccounts(for: .cursor).first", expectedProviderIDs: ["cursor"], reason: "The Cursor-only cookie-settings resolver passes its fixed identity to token-account helpers."), SuppressedProviderReference( path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 391, + line: 532, anchor: "return context.settingsSnapshot(for: .cursor, account: account)?.cursor", expectedProviderIDs: ["cursor"], reason: "The Cursor-only cookie-settings resolver passes its fixed identity to token-account helpers."), @@ -3291,7 +3291,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact CLI construct preserves the provider-specific command and output contract."), AllowedProviderConstruct( path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 208, + line: 286, anchor: "provider == .codex", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3299,7 +3299,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact CLI construct preserves the provider-specific command and output contract."), AllowedProviderConstruct( path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 228, + line: 369, anchor: "let projects = provider == .codex", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3307,7 +3307,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact CLI construct preserves the provider-specific command and output contract."), AllowedProviderConstruct( path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 400, + line: 541, anchor: "guard provider == .cursor else { return nil }", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3315,7 +3315,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact CLI construct preserves the provider-specific command and output contract."), AllowedProviderConstruct( path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 420, + line: 561, anchor: "guard provider == .cursor, settings?.cookieSource == .manual else { return nil }", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, diff --git a/docs/cli.md b/docs/cli.md index c910a4ae49..4ca13066e4 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -212,6 +212,7 @@ codexbar --format json --provider both 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 codex --group-by session codexbar cost --provider claude --format json --pretty codexbar guard --provider codex --min-remaining 20 --window weekly --json codexbar cost --provider cursor # Cursor dashboard cost (API-rate + Cursor-metered)