Skip to content
80 changes: 61 additions & 19 deletions Sources/CodexBarCore/CostUsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,8 @@ public struct CostUsageFetcher: Sendable {
private static func resolvedScannerOptions(
_ override: CostUsageScanner.Options?,
provider: UsageProvider,
codexHomePath: String?) -> CostUsageScanner.Options
codexHomePath: String?,
allowVertexClaudeFallback: Bool = false) -> CostUsageScanner.Options
{
var options = override ?? CostUsageScanner.Options()
// Provider-specific by design: Codex managed profiles relocate sessions and archived_sessions roots.
Expand All @@ -346,6 +347,11 @@ public struct CostUsageFetcher: Sendable {
options.codexSessionsRoot = URL(fileURLWithPath: codexHomePath, isDirectory: true)
.appendingPathComponent("sessions", isDirectory: true)
}
if provider == .vertexai {
options.claudeLogProviderFilter = allowVertexClaudeFallback ? .all : .vertexAIOnly
} else if provider == .claude {
options.claudeLogProviderFilter = .excludeVertexAI
}
return options
}

Expand Down Expand Up @@ -387,12 +393,10 @@ public struct CostUsageFetcher: Sendable {
var options = Self.resolvedScannerOptions(
overrideScannerOptions,
provider: provider,
codexHomePath: codexHomePath)
codexHomePath: codexHomePath,
allowVertexClaudeFallback: allowVertexClaudeFallback)
// Rolling window is inclusive, so a 30-day display starts 29 days before `now`.
let since = options.calendar.date(byAdding: .day, value: -(clampedHistoryDays - 1), to: now) ?? now
let scopedCodexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines)
// Provider-specific by design: scoped Codex homes exclude ambient Pi sessions from managed-profile totals.
let shouldMergePiUsage = provider != .codex || scopedCodexHomePath?.isEmpty != false
await Self.refreshPricingIfAllowed(
options: PricingRefreshOptions(
provider: provider,
Expand All @@ -406,7 +410,6 @@ public struct CostUsageFetcher: Sendable {
Self.configureScannerRefresh(
&options,
provider: provider,
allowVertexClaudeFallback: allowVertexClaudeFallback,
forceRefresh: forceRefresh,
bypassScannerDebounce: bypassScannerDebounce)
var resolvedPiOptions = overridePiScannerOptions ?? PiSessionCostScanner.Options()
Expand All @@ -423,7 +426,7 @@ public struct CostUsageFetcher: Sendable {
let localScanOptions = LocalTokenScanOptions(
allowVertexClaudeFallback: allowVertexClaudeFallback,
includePiSessions: includePiSessions,
shouldMergePiUsage: shouldMergePiUsage,
codexHomePath: codexHomePath,
scanOptions: scanOptions,
piOptions: piOptions)
let scanResult = try await Self.loadLocalTokenScanResult(
Expand Down Expand Up @@ -482,7 +485,7 @@ public struct CostUsageFetcher: Sendable {
private struct LocalTokenScanOptions: Sendable {
let allowVertexClaudeFallback: Bool
let includePiSessions: Bool
let shouldMergePiUsage: Bool
let codexHomePath: String?
let scanOptions: CostUsageScanner.Options
let piOptions: PiSessionCostScanner.Options
}
Expand Down Expand Up @@ -529,6 +532,7 @@ public struct CostUsageFetcher: Sendable {
var sessions: [CostUsageSessionBreakdown] = []
var piDaily: CostUsageDailyReport?
var staleSnapshotUpdatedAt: Date?
// Provider-specific by design: only Codex builds project and session breakdowns from its local cache.
if provider == .codex {
let roots = CostUsageScanner.codexSessionsRoots(options: options.scanOptions)
let cache = CostUsageScanner.codexCache(
Expand Down Expand Up @@ -556,8 +560,10 @@ public struct CostUsageFetcher: Sendable {
sessionRoots: roots)
}
}
if options.includePiSessions,
provider == .claude || (provider == .codex && options.shouldMergePiUsage)
if Self.shouldMergePiSessions(
provider: provider,
includePiSessions: options.includePiSessions,
codexHomePath: options.codexHomePath)
{
let piReport = try PiSessionCostScanner.loadDailyReportCancellable(
provider: provider,
Expand All @@ -567,6 +573,7 @@ public struct CostUsageFetcher: Sendable {
options: options.piOptions,
checkCancellation: checkCancellation)
try checkCancellation()
// Provider-specific by design: only Codex stores the Pi-only report for project merge.
if provider == .codex {
piDaily = piReport
}
Expand Down Expand Up @@ -604,7 +611,7 @@ public struct CostUsageFetcher: Sendable {
{
guard options.isAllowed,
options.retryUnknown,
options.provider == .codex || options.provider == .claude
self.usesModelsDevPricing(options.provider)
else { return }

if options.inBackground {
Expand All @@ -631,10 +638,11 @@ public struct CostUsageFetcher: Sendable {
cacheRoot: URL?,
client: ModelsDevClient) -> UnknownPricingRefreshRequest?
{
guard provider == .codex || provider == .claude else { return nil }
guard let providerID = self.modelsDevRefreshProviderID(for: provider) else { return nil }
let unknownModelIDs = Set(daily.data.flatMap { entry in
entry.modelBreakdowns?.compactMap { breakdown -> String? in
guard breakdown.costUSD == nil else { return nil }
// Provider-specific by design: only Codex filters out its own unattributed model names.
if provider == .codex,
CostUsagePricing.isCodexUnattributedModel(breakdown.modelName)
{
Expand All @@ -646,7 +654,7 @@ public struct CostUsageFetcher: Sendable {
guard !unknownModelIDs.isEmpty else { return nil }

return UnknownPricingRefreshRequest(
providerID: provider == .codex ? "openai" : "anthropic",
providerID: providerID,
modelIDs: unknownModelIDs,
now: now,
cacheRoot: cacheRoot,
Expand Down Expand Up @@ -1108,15 +1116,11 @@ public struct CostUsageFetcher: Sendable {
private static func configureScannerRefresh(
_ options: inout CostUsageScanner.Options,
provider: UsageProvider,
allowVertexClaudeFallback: Bool,
forceRefresh: Bool,
bypassScannerDebounce: Bool)
{
if provider == .vertexai {
options.claudeLogProviderFilter = allowVertexClaudeFallback ? .all : .vertexAIOnly
} else if provider == .claude {
options.claudeLogProviderFilter = .excludeVertexAI
}
// `claudeLogProviderFilter` is configured in `resolvedScannerOptions` so it is available
// to every caller, not only this path.
if forceRefresh || bypassScannerDebounce {
options.refreshMinIntervalSeconds = 0
}
Expand Down Expand Up @@ -1445,6 +1449,43 @@ extension CostUsageFetcher {
return "v2:\(scopedFiles.count):\(progressHasher.finalize())"
}

fileprivate static func shouldMergePiSessions(
provider: UsageProvider,
includePiSessions: Bool,
codexHomePath: String?) -> Bool
{
// Provider-specific by design: Pi session mirrors exist only for Google/xAI, Claude, and Codex.
let scopedCodexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines)
let shouldMergePiUsage = provider != .codex || scopedCodexHomePath?.isEmpty != false
return includePiSessions
&& (provider == .claude
|| (provider == .codex && shouldMergePiUsage)
|| provider == .gemini
|| provider == .vertexai
|| provider == .grok)
}

fileprivate static func usesModelsDevPricing(_ provider: UsageProvider) -> Bool {
self.modelsDevRefreshProviderID(for: provider) != nil
}

fileprivate static func modelsDevRefreshProviderID(for provider: UsageProvider) -> String? {
switch provider {
case .codex:
"openai"
case .claude:
"anthropic"
case .gemini:
"google"
case .vertexai:
"google-vertex"
case .grok:
"xai"
default:
nil
}
}

fileprivate static func loadRemoteTokenSnapshot(
provider: UsageProvider,
environment: [String: String],
Expand All @@ -1467,6 +1508,7 @@ extension CostUsageFetcher {
}

#if os(macOS)
// Provider-specific by design: Cursor remote snapshots use its macOS dashboard session.
if provider == .cursor {
return try await self.loadCursorTokenSnapshot(
now: now,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand.

enum CodexParserHash {
static let value = "e2899fcb0234e5c1"
static let value = "dd667cc833886c16"
}
78 changes: 73 additions & 5 deletions Sources/CodexBarCore/PiSessionCostScanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ enum PiSessionCostScanner {

private static let costScale = 1_000_000_000.0
/// Bump for Pi-only cost formula changes not represented by the parser or pricing fingerprints.
private static let costFormulaVersion = 1
private static let costFormulaVersion = 2
private static let maxLineBytes = 16 * 1024 * 1024
private static let maxSafeRoundedInt = Double(Int.max) - 1
private static let sessionStartFilenameRegex = try? NSRegularExpression(
Expand Down Expand Up @@ -107,8 +107,7 @@ enum PiSessionCostScanner {
options: Options = Options(),
checkCancellation: CostUsageScanner.CancellationCheck?) throws -> CostUsageDailyReport
{
// Provider-specific by design: Pi records only OpenAI Codex and Anthropic sessions with distinct pricing.
guard provider == .codex || provider == .claude else {
guard self.supportsPiSessionProvider(provider) else {
return CostUsageDailyReport(data: [], summary: nil)
}

Expand Down Expand Up @@ -225,6 +224,7 @@ enum PiSessionCostScanner {
cacheRoot: URL? = nil,
calendar: Calendar = .current) -> CachedDailyReportResult?
{
// Provider-specific by design: cached Pi reports are only supported for Codex and Claude.
guard provider == .codex || provider == .claude else { return nil }

let range = CostUsageScanner.CostUsageDayRange(since: since, until: until, calendar: calendar)
Expand All @@ -249,14 +249,21 @@ enum PiSessionCostScanner {

private static func pricingContext(now: Date, cacheRoot: URL?) -> ModelsDevPricingContext {
let modelsDevArtifact = ModelsDevCache.load(now: now, cacheRoot: cacheRoot).artifact
// Provider-specific by design: Pi pricing pulls models.dev catalogs only for the supported vendors.
return ModelsDevPricingContext(
catalog: modelsDevArtifact?.catalog,
cacheRoot: cacheRoot,
pricingKey: CostUsagePricingKey.codex(
modelsDevArtifact: modelsDevArtifact,
formulaVersion: Self.costFormulaVersion,
parserHash: CodexParserHash.value,
modelsDevProviderIDs: ["anthropic", "openai"]))
modelsDevProviderIDs: [
"anthropic",
"google",
"google-vertex",
"openai",
"xai",
]))
}

private static func requestedWindowExpandsCache(
Expand Down Expand Up @@ -830,6 +837,7 @@ enum PiSessionCostScanner {
pricingDate: Date? = nil,
pricingContext: ModelsDevPricingContext? = nil) -> Double?
{
// Provider-specific by design: Pi cost calculation uses Codex/Claude-specific pricing normalizers.
switch provider {
case .codex:
// Pi records input, cache reads, and cache writes as disjoint counts. Codex pricing
Expand All @@ -854,7 +862,11 @@ enum PiSessionCostScanner {
modelsDevCatalog: pricingContext?.catalog,
modelsDevCacheRoot: pricingContext?.cacheRoot)
default:
nil
self.modelsDevCostUSD(
provider: provider,
model: modelName,
usage: usage,
pricingContext: pricingContext)
}
}

Expand All @@ -878,16 +890,72 @@ enum PiSessionCostScanner {

extension PiSessionCostScanner {
private static func mappedProvider(fromPiProvider provider: String) -> UsageProvider? {
// Provider-specific by design: Pi provider strings map to their respective UsageProvider values.
switch provider.lowercased() {
case "openai-codex":
.codex
case "anthropic":
.claude
case "google", "gemini":
.gemini
case "google-vertex", "vertexai", "vertex-ai":
.vertexai
case "xai", "x.ai", "grok":
.grok
default:
nil
}
}

private static func supportsPiSessionProvider(_ provider: UsageProvider) -> Bool {
switch provider {
case .codex, .claude, .gemini, .vertexai, .grok:
true
default:
false
}
}

private static func modelsDevCostUSD(
provider: UsageProvider,
model: String,
usage: PiPackedUsage,
pricingContext: ModelsDevPricingContext?) -> Double?
{
guard let lookup = CostUsagePricing.modelsDevPricing(
provider: provider,
model: model,
catalog: pricingContext?.catalog,
cacheRoot: pricingContext?.cacheRoot)
else { return nil }

let pricing = lookup.pricing
let totalInput = max(0, usage.inputTokens + usage.cacheReadTokens + usage.cacheWriteTokens)
let cached = min(max(0, usage.cacheReadTokens), totalInput)
let remainingAfterCache = totalInput - cached
let cacheWrite = min(max(0, usage.cacheWriteTokens), remainingAfterCache)
let nonCached = remainingAfterCache - cacheWrite
let usesLongContextRates = pricing.thresholdTokens.map { totalInput > $0 } ?? false
let inputRate = usesLongContextRates
? pricing.inputCostPerTokenAboveThreshold ?? pricing.inputCostPerToken
: pricing.inputCostPerToken
let cachedInputRate = usesLongContextRates
? pricing.cacheReadInputCostPerTokenAboveThreshold ?? pricing.cacheReadInputCostPerToken ?? inputRate
: pricing.cacheReadInputCostPerToken ?? pricing.inputCostPerToken
let cacheWriteRate = usesLongContextRates
? pricing.cacheCreationInputCostPerTokenAboveThreshold ?? pricing
.cacheCreationInputCostPerToken ?? inputRate
: pricing.cacheCreationInputCostPerToken ?? inputRate
let outputRate = usesLongContextRates
? pricing.outputCostPerTokenAboveThreshold ?? pricing.outputCostPerToken
: pricing.outputCostPerToken

return (Double(nonCached) * inputRate)
+ (Double(cached) * cachedInputRate)
+ (Double(cacheWrite) * cacheWriteRate)
+ (Double(max(0, usage.outputTokens)) * outputRate)
}

private static func buildReport(
provider: UsageProvider,
cache: PiSessionCostCache,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ public enum GeminiProviderDescriptor {
burnDownWidgetColor: ProviderColor(red: 0.420, green: 0.440, blue: 0.900)),
tokenCost: ProviderTokenCostConfig(
supportsTokenCost: false,
noDataMessage: { "Gemini cost summary is not supported." }),
noDataMessage: { "Gemini cost summary is not supported." },
supportsTokenSnapshot: true),
presentation: ProviderUsagePresentation(
identityPresenter: { provider, snapshot in
guard let plan = snapshot.loginMethod(for: provider), !plan.isEmpty else {
Expand All @@ -61,7 +62,8 @@ public enum GeminiProviderDescriptor {
cli: ProviderCLIConfig(
name: "gemini",
binaryLocator: { BinaryLocator.resolveGeminiBinary() },
versionDetector: { _ in ProviderVersionDetector.geminiVersion() }))
versionDetector: { _ in ProviderVersionDetector.geminiVersion() },
supportsCostCommand: true))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ public enum GrokProviderDescriptor {
]),
tokenCost: ProviderTokenCostConfig(
supportsTokenCost: false,
noDataMessage: { "Grok cost summary is not supported yet." }),
noDataMessage: { "Grok cost summary is not supported yet." },
supportsTokenSnapshot: true),
pace: ProviderPaceCapability(resetWindowPace: .custom { window, now in
guard Self.primaryLabel(window: window, now: now) == "Weekly",
let resetsAt = window.resetsAt
Expand All @@ -72,6 +73,7 @@ public enum GrokProviderDescriptor {
cli: ProviderCLIConfig(
name: "grok",
versionDetector: { _ in GrokStatusProbe.detectVersion() },
supportsCostCommand: true,
browserSupportExemption: { _, _, _ in true }))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ public enum VertexAIProviderDescriptor {
pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [VertexAIOAuthFetchStrategy()] })),
cli: ProviderCLIConfig(
name: "vertexai",
versionDetector: nil))
versionDetector: nil,
supportsCostCommand: true))
}
}

Expand Down
Loading