Skip to content
83 changes: 64 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,46 @@ 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 the China API group, Claude, and Codex.
let scopedCodexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines)
let shouldMergePiUsage = provider != .codex || scopedCodexHomePath?.isEmpty != false
return includePiSessions
&& (provider == .claude
|| (provider == .codex && shouldMergePiUsage)
|| provider == .alibaba
|| provider == .alibabatokenplan
|| provider == .zai
|| provider == .deepseek)
}

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 .alibaba:
"alibaba-coding-plan"
case .alibabatokenplan:
"alibaba-token-plan"
case .zai:
"zai"
case .deepseek:
"deepseek"
default:
nil
}
}

fileprivate static func loadRemoteTokenSnapshot(
provider: UsageProvider,
environment: [String: String],
Expand All @@ -1467,6 +1511,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 = "3f8a4d2dde3482e9"
}
88 changes: 82 additions & 6 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,7 +224,7 @@ enum PiSessionCostScanner {
cacheRoot: URL? = nil,
calendar: Calendar = .current) -> CachedDailyReportResult?
{
guard provider == .codex || provider == .claude else { return nil }
guard self.supportsPiSessionProvider(provider) else { return nil }

let range = CostUsageScanner.CostUsageDayRange(since: since, until: until, calendar: calendar)
let cache = PiSessionCostCacheIO.load(cacheRoot: cacheRoot)
Expand All @@ -249,14 +248,26 @@ 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: [
"alibaba",
"alibaba-coding-plan",
"alibaba-coding-plan-cn",
"alibaba-token-plan",
"alibaba-token-plan-cn",
"anthropic",
"deepseek",
"openai",
"zai",
"zai-coding-plan",
]))
}

private static func requestedWindowExpandsCache(
Expand Down Expand Up @@ -830,6 +841,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 +866,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 +894,34 @@ 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 "alibaba", "alibaba-coding-plan", "qwen", "qwen-code":
.alibaba
case "alibaba-token-plan":
.alibabatokenplan
case "zai", "z.ai", "z-ai":
.zai
case "deepseek":
.deepseek
default:
nil
}
}

private static func supportsPiSessionProvider(_ provider: UsageProvider) -> Bool {
switch provider {
case .codex, .claude, .alibaba, .alibabatokenplan, .zai, .deepseek:
true
default:
false
}
}

private static func buildReport(
provider: UsageProvider,
cache: PiSessionCostCache,
Expand Down Expand Up @@ -1116,3 +1150,45 @@ extension PiSessionCostScanner {
}
}
}

extension PiSessionCostScanner {
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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ public enum AlibabaCodingPlanProviderDescriptor {
]),
tokenCost: ProviderTokenCostConfig(
supportsTokenCost: false,
noDataMessage: { "Alibaba Coding Plan cost summary is not supported." }),
noDataMessage: { "Alibaba Coding Plan cost summary is not supported." },
supportsTokenSnapshot: true),
pace: .calendarMonthResetWindow,
presentation: ProviderUsagePresentation(
primaryBindingQuotaLanes: [.secondary, .tertiary],
Expand All @@ -93,7 +94,8 @@ public enum AlibabaCodingPlanProviderDescriptor {
cli: ProviderCLIConfig(
name: "alibaba-coding-plan",
aliases: ["alibaba", "bailian"],
versionDetector: nil))
versionDetector: nil,
supportsCostCommand: true))
}

private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ public enum AlibabaTokenPlanProviderDescriptor {
]),
tokenCost: ProviderTokenCostConfig(
supportsTokenCost: false,
noDataMessage: { "Alibaba Token Plan cost summary is not supported." }),
noDataMessage: { "Alibaba Token Plan cost summary is not supported." },
supportsTokenSnapshot: true),
pace: .calendarMonthResetWindow,
presentation: ProviderUsagePresentation(
primaryBindingQuotaLanes: [.secondary],
Expand All @@ -105,6 +106,7 @@ public enum AlibabaTokenPlanProviderDescriptor {
name: "alibaba-token-plan",
aliases: ["alibaba-token", "bailian-token-plan"],
versionDetector: nil,
supportsCostCommand: true,
browserSupportExemption: { _, _, settings in
// Manual cookies use plain URLSession; only browser import is platform-bound.
settings?.alibabaTokenPlan?.cookieSource == .manual
Expand Down
Loading