Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Sources/CodexBar/Menu/CostHistoryChartMenuView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1208,8 +1208,8 @@ extension CostHistoryChartMenuView {

/// Providers whose local session ledgers publish project and conversation rows.
nonisolated static func showsLocalBreakdowns(provider: UsageProvider) -> Bool {
// Provider-specific by design: only Codex and Pi session ledgers carry project/session rows.
provider == .codex || provider == .pi
// Provider-specific by design: only Codex, Claude, and Pi session ledgers carry project/session rows.
provider == .codex || provider == .claude || provider == .pi
}

static func renderFingerprint(
Expand Down
86 changes: 45 additions & 41 deletions Sources/CodexBarCLI/CLICostCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,12 @@ extension CodexBarCLI {
let selection = CodexBarCLI.decodeProvider(from: values, config: config, output: output)
let providers = Self.costProviders(from: selection)
let unsupported = selection.asList.filter { !Self.costSupportedProviders.contains($0) }
if !unsupported.isEmpty {
if !unsupported.isEmpty, !output.jsonOnly {
let names = unsupported
.map { ProviderDescriptorRegistry.descriptor(for: $0).metadata.displayName }
.sorted()
.joined(separator: ", ")
if !output.jsonOnly {
Self.writeStderr("Skipping providers without local cost usage: \(names)\n")
}
Self.writeStderr("Skipping providers without local cost usage: \(names)\n")
}
guard !providers.isEmpty else {
Self.exit(
Expand Down Expand Up @@ -70,17 +68,16 @@ extension CodexBarCLI {
}
let groupBy = Self.decodeCostGroupBy(from: values)
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,
selectedProviders: providers,
groupBy: groupBy,
format: format,
includePiSessions: includePiSessions)
{
Self.writeStderr("Warning: \(warning)\n")
for provider in providers where !output.jsonOnly {
if let warning = Self.sessionGroupingPiOmissionWarning(
provider: provider,
selectedProviders: providers,
groupBy: groupBy,
format: format,
includePiSessions: includePiSessions)
{
Self.writeStderr("Warning: \(warning)\n")
}
}

let fetcher = CostUsageFetcher(calendar: bucketCalendar)
Expand All @@ -92,7 +89,7 @@ extension CodexBarCLI {
var payload: [CostPayload] = []
var exitCode: ExitCode = .success

// Provider-specific by design: project/session grouping is available only for Codex and Pi local session data.
// Provider-specific by design: project/session grouping is available only for local session data.
for provider in outputProviders {
if let error = Self.cursorCostAvailabilityError(
provider,
Expand Down Expand Up @@ -180,7 +177,7 @@ extension CodexBarCLI {
case project
case session

/// Groupings that depend on local session data (`costGroupingProviders`) and only apply to text output.
/// Groupings that depend on local project/session data and only apply to text output.
var requiresLocalSessionData: Bool {
switch self {
case .none: false
Expand All @@ -190,7 +187,7 @@ extension CodexBarCLI {
}

/// Providers whose local session ledgers publish project and session rows.
static let costGroupingProviders: Set<UsageProvider> = [.codex, .pi]
static let costGroupingProviders: Set<UsageProvider> = [.codex, .claude, .pi]

static func renderCostText(
provider: UsageProvider,
Expand All @@ -216,19 +213,28 @@ extension CodexBarCLI {
: "\(name) Cost (API-rate estimate)"
let header = Self.costHeaderLine(title, useColor: useColor)
let historyNotice = snapshot.historyIsFullyScanned ? nil : "Partial local history · recorded token subtotal"
let incomplete = CostUsageIncompleteRequests.sum(snapshot.daily.map(\.incompleteRequestCount))
let unpriced = snapshot.daily.reduce(0) { $0 + max(0, $1.unpricedRequestCount ?? 0) }
var notices: [String] = []
if incomplete > 0 {
notices
.append("Incomplete: \(incomplete) requests lacked final usage and were excluded from tokens and cost.")
}
if unpriced > 0 {
notices.append("Partial estimate: \(unpriced) recorded request\(unpriced == 1 ? "" : "s") had no price.")
}
if groupBy == .project, Self.costGroupingProviders.contains(provider) {
return Self.renderProjectCostText(
provider: provider,
header: [header, historyNotice].compactMap(\.self).joined(separator: "\n"),
header: ([header, historyNotice].compactMap(\.self) + notices).joined(separator: "\n"),
snapshot: snapshot)
}
if groupBy == .session, Self.costGroupingProviders.contains(provider) {
return Self.renderSessionCostText(provider: provider, header: header, snapshot: snapshot)
return Self.renderSessionCostText(
provider: provider, header: ([header] + notices).joined(separator: "\n"), snapshot: snapshot)
}

let todayIncomplete = snapshot.summary(forLastDays: 1, calendar: calendar).incompleteRequestCount
let incomplete = CostUsageIncompleteRequests.sum(snapshot.daily.map(\.incompleteRequestCount))
let unpriced = snapshot.daily.reduce(0) { $0 + max(0, $1.unpricedRequestCount ?? 0) }
let todayCost = snapshot.sessionCostUSD
.map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—"
let todayTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) }
Expand All @@ -255,13 +261,7 @@ extension CodexBarCLI {
monthLine + (incomplete > 0 ? " · Incomplete" : ""),
meteredLine,
]
if incomplete > 0 {
lines
.append("Incomplete: \(incomplete) requests lacked final usage and were excluded from tokens and cost.")
}
if unpriced > 0 {
lines.append("Partial estimate: \(unpriced) recorded request\(unpriced == 1 ? "" : "s") had no price.")
}
lines.append(contentsOf: notices)
lines.append(historyNotice)
// Provider-specific by design: Claude and OpenCode Go local history retain model attribution.
if includeBreakdown, provider == .claude || provider == .opencodego, !snapshot.daily.isEmpty {
Expand Down Expand Up @@ -505,7 +505,10 @@ extension CodexBarCLI {
if let path = project.path {
lines.append(" \(path)")
}
for source in project.sources {
// A sole source that repeats the project itself adds no information.
let sources = project.sources.count == 1 && project.sources[0].path == project.path
&& project.sources[0].name == project.name ? [] : project.sources
for source in sources {
let sourceCost = source.totalCostUSD
.map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—"
let sourceTokens = source.totalTokens.map { UsageFormatter.tokenCountString($0) }
Expand Down Expand Up @@ -609,20 +612,20 @@ extension CodexBarCLI {
return await LocalAgentSessionScanner().piSessionProcessContexts()
}

/// Providers participating in a cost run: text-mode project/session grouping keeps only
/// `costGroupingProviders`, while JSON output always keeps every requested provider.
/// Providers participating in a cost run: text-mode project/session grouping keeps only providers with
/// local session data, while JSON output always keeps every requested provider.
static func costProviders(
_ providers: [UsageProvider],
groupBy: CostGroupBy,
format: OutputFormat) -> [UsageProvider]
{
// Provider-specific by design: text grouping needs local session ledgers; JSON keeps all providers.
// Provider-specific by design: text grouping relies on local session indexes while JSON preserves all.
providers.filter {
!groupBy.requiresLocalSessionData || Self.costGroupingProviders.contains($0) || format == .json
}
}

/// Session text reports need native Codex rows, so keep Pi/OMP aggregate merging out of that path.
/// Session text reports need native rows, so keep Pi/OMP aggregate merging out of that path.
static func costIncludePiSessions(
provider: UsageProvider,
selectedProviders: [UsageProvider] = [],
Expand All @@ -635,8 +638,9 @@ extension CodexBarCLI {
if provider == .claude || provider == .codex, selectedProviders.contains(.pi) {
return false
}
// Provider-specific by design: only Codex local session text bypasses Pi/OMP merging.
guard provider == .codex, groupBy == .session, format == .text else { return includePiSessions }
// Provider-specific by design: only local session text bypasses Pi/OMP merging.
guard self.costGroupingProviders.contains(provider), groupBy == .session, format == .text
else { return includePiSessions }
return false
}

Expand All @@ -647,19 +651,19 @@ extension CodexBarCLI {
format: OutputFormat,
includePiSessions: Bool) -> String?
{
// Provider-specific by design: only Codex local session text warns about omitted mirrors.
// A selected Pi provider prints those rows itself, so nothing is omitted.
guard provider == .codex,
// Provider-specific by design: only local session text warns about omitted mirrors.
guard provider == .codex || provider == .claude,
!selectedProviders.contains(.pi),
groupBy == .session,
format == .text,
includePiSessions
else { return nil }
return "Session grouping shows native Codex conversations only; Pi/OMP usage is omitted from this view. "
let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName
return "Session grouping shows native \(name) conversations only; Pi/OMP usage is omitted from this view. "
+ "Use the default cost view for merged totals."
}

/// Provider-specific by design: only Codex and Pi session ledgers carry local project/session rows,
/// Provider-specific by design: only Codex, Claude, and Pi transcripts carry local project/session indexes,
/// so text-mode grouping skips other providers with a concise stderr notice.
static func warnSkippedGroupingProviders(
groupBy: CostGroupBy,
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBarCLI/CLIHelp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ extension CodexBarCLI {
codexbar cost
codexbar cost --provider codex --group-by project
codexbar cost --provider codex --group-by session
codexbar cost --provider claude --group-by session
codexbar cost --provider pi --group-by session
codexbar cost --provider claude --format json --pretty
codexbar cost --provider antigravity --format json
Expand Down
45 changes: 30 additions & 15 deletions Sources/CodexBarCore/CostUsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -932,19 +932,35 @@ public struct CostUsageFetcher: Sendable {
let sourceRefreshSucceeded = try await OpenCodeLocalHistoryAdapter.refresh(
scope: sourceScope, cacheRoot: options.scanOptions.cacheRoot, calendar: options.scanOptions.calendar)

// Provider-specific by design: Codex owns project/session attribution and optional Pi merge state, while
// Claude/Vertex share the transcript scanner with mutually exclusive filters.
// Provider-specific by design: Codex and Claude own project/session attribution and optional Pi merge
// state, while Claude/Vertex share the transcript scanner with mutually exclusive filters.
// These synchronous scans can run for minutes on large archives. The dedicated queue keeps
// them off the cooperative pool and bridges task cancellation into scanner-level checks.
return try await CostUsageScanExecutor.run { checkCancellation in
var daily = try CostUsageScanner.loadDailyReportCancellable(
provider: provider,
since: since,
until: now,
now: now,
options: options.scanOptions,
reportContext: options.reportContext,
checkCancellation: checkCancellation)
var projects: [CostUsageProjectBreakdown] = []
var sessions: [CostUsageSessionBreakdown] = []
var daily: CostUsageDailyReport
if provider == .claude {
let claude = try CostUsageScanner.loadClaudeReportProjectionsCancellable(
since: since,
until: now,
now: now,
options: options.scanOptions,
reportContext: options.reportContext,
checkCancellation: checkCancellation)
daily = claude.daily
projects = claude.projects
sessions = claude.sessions
} else {
daily = try CostUsageScanner.loadDailyReportCancellable(
provider: provider,
since: since,
until: now,
now: now,
options: options.scanOptions,
reportContext: options.reportContext,
checkCancellation: checkCancellation)
}
try checkCancellation()

if provider == .vertexai,
Expand All @@ -965,8 +981,6 @@ public struct CostUsageFetcher: Sendable {
try checkCancellation()
}

var projects: [CostUsageProjectBreakdown] = []
var sessions: [CostUsageSessionBreakdown] = []
var piScanIsComplete = true
var staleSnapshotUpdatedAt: Date?
var detailsAreComplete = true
Expand Down Expand Up @@ -1025,8 +1039,9 @@ public struct CostUsageFetcher: Sendable {
.compactMap(\.self)
.min()
}
// Provider-specific by design: only the Codex local ledger contributes Pi rows to this scan.
if provider == .codex {
// Provider-specific by design: only the Codex and Claude local ledgers attribute native rows to
// projects, so Pi rows join them as one unknown project and sessions stay native-only.
if provider == .codex || provider == .claude {
if let project = Self.unknownProjectBreakdown(from: piScanResult.report) {
projects.append(project)
sessions = []
Expand All @@ -1039,7 +1054,7 @@ public struct CostUsageFetcher: Sendable {
[daily, piScanResult.report],
calendar: options.scanOptions.calendar)
}
if provider == .codex {
if provider == .codex || provider == .claude {
projects = Self.mergedProjectBreakdowns(projects)
}
return LocalTokenScanResult(
Expand Down
Loading
Loading