739 lines
30 KiB
Swift
739 lines
30 KiB
Swift
import Foundation
|
|
|
|
public enum CostUsageError: LocalizedError, Sendable {
|
|
case unsupportedProvider(UsageProvider)
|
|
case timedOut(seconds: Int)
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case let .unsupportedProvider(provider):
|
|
return "Cost summary is not supported for \(provider.rawValue)."
|
|
case let .timedOut(seconds):
|
|
if seconds >= 60, seconds % 60 == 0 {
|
|
return "Cost refresh timed out after \(seconds / 60)m."
|
|
}
|
|
return "Cost refresh timed out after \(seconds)s."
|
|
}
|
|
}
|
|
}
|
|
|
|
public struct CostUsageFetcher: Sendable {
|
|
package struct CachedCodexTokenSnapshotResult: Sendable {
|
|
package let snapshot: CostUsageTokenSnapshot
|
|
package let lastRefreshAt: Date?
|
|
}
|
|
|
|
private let scannerOptions: CostUsageScanner.Options?
|
|
|
|
public init(cacheRoot: URL? = nil) {
|
|
self.scannerOptions = cacheRoot.map { CostUsageScanner.Options(cacheRoot: $0) }
|
|
}
|
|
|
|
init(scannerOptions: CostUsageScanner.Options) {
|
|
self.scannerOptions = scannerOptions
|
|
}
|
|
|
|
public func loadCachedCodexTokenSnapshot(
|
|
now: Date = Date(),
|
|
codexHomePath: String? = nil,
|
|
historyDays: Int = 30) async -> CostUsageTokenSnapshot?
|
|
{
|
|
await Self.loadCachedCodexTokenSnapshot(
|
|
now: now,
|
|
codexHomePath: codexHomePath,
|
|
historyDays: historyDays,
|
|
scannerOptions: self.scannerOptionsOverride())
|
|
}
|
|
|
|
package func loadCachedCodexTokenSnapshotResult(
|
|
now: Date = Date(),
|
|
codexHomePath: String? = nil,
|
|
historyDays: Int = 30) async -> CachedCodexTokenSnapshotResult?
|
|
{
|
|
await Self.loadCachedCodexTokenSnapshotResult(
|
|
now: now,
|
|
codexHomePath: codexHomePath,
|
|
historyDays: historyDays,
|
|
scannerOptions: self.scannerOptionsOverride())
|
|
}
|
|
|
|
public func loadTokenSnapshot(
|
|
provider: UsageProvider,
|
|
environment: [String: String] = ProcessInfo.processInfo.environment,
|
|
now: Date = Date(),
|
|
forceRefresh: Bool = false,
|
|
allowVertexClaudeFallback: Bool = false,
|
|
codexHomePath: String? = nil,
|
|
historyDays: Int = 30,
|
|
refreshPricingInBackground: Bool = true) async throws -> CostUsageTokenSnapshot
|
|
{
|
|
try await Self.loadTokenSnapshot(
|
|
provider: provider,
|
|
environment: environment,
|
|
now: now,
|
|
forceRefresh: forceRefresh,
|
|
allowVertexClaudeFallback: allowVertexClaudeFallback,
|
|
codexHomePath: codexHomePath,
|
|
historyDays: historyDays,
|
|
refreshPricingInBackground: refreshPricingInBackground,
|
|
bypassScannerDebounce: false,
|
|
scannerOptions: self.scannerOptionsOverride())
|
|
}
|
|
|
|
package func loadTokenSnapshot(
|
|
provider: UsageProvider,
|
|
environment: [String: String] = ProcessInfo.processInfo.environment,
|
|
now: Date = Date(),
|
|
forceRefresh: Bool = false,
|
|
allowVertexClaudeFallback: Bool = false,
|
|
codexHomePath: String? = nil,
|
|
historyDays: Int = 30,
|
|
refreshPricingInBackground: Bool = true,
|
|
bypassScannerDebounce: Bool) async throws -> CostUsageTokenSnapshot
|
|
{
|
|
try await Self.loadTokenSnapshot(
|
|
provider: provider,
|
|
environment: environment,
|
|
now: now,
|
|
forceRefresh: forceRefresh,
|
|
allowVertexClaudeFallback: allowVertexClaudeFallback,
|
|
codexHomePath: codexHomePath,
|
|
historyDays: historyDays,
|
|
refreshPricingInBackground: refreshPricingInBackground,
|
|
bypassScannerDebounce: bypassScannerDebounce,
|
|
scannerOptions: self.scannerOptionsOverride())
|
|
}
|
|
|
|
@available(*, deprecated, message: "Codex token-cost scans are uncapped; this limit is ignored.")
|
|
public func loadTokenSnapshot(
|
|
provider: UsageProvider,
|
|
environment: [String: String] = ProcessInfo.processInfo.environment,
|
|
now: Date = Date(),
|
|
forceRefresh: Bool = false,
|
|
allowVertexClaudeFallback: Bool = false,
|
|
codexHomePath: String? = nil,
|
|
historyDays: Int = 30,
|
|
refreshPricingInBackground: Bool = true,
|
|
automaticCodexScanByteLimit _: Int64?) async throws -> CostUsageTokenSnapshot
|
|
{
|
|
try await self.loadTokenSnapshot(
|
|
provider: provider,
|
|
environment: environment,
|
|
now: now,
|
|
forceRefresh: forceRefresh,
|
|
allowVertexClaudeFallback: allowVertexClaudeFallback,
|
|
codexHomePath: codexHomePath,
|
|
historyDays: historyDays,
|
|
refreshPricingInBackground: refreshPricingInBackground)
|
|
}
|
|
|
|
private func scannerOptionsOverride() -> CostUsageScanner.Options? {
|
|
self.scannerOptions
|
|
}
|
|
|
|
static func loadTokenSnapshot(
|
|
provider: UsageProvider,
|
|
environment: [String: String] = ProcessInfo.processInfo.environment,
|
|
now: Date = Date(),
|
|
forceRefresh: Bool = false,
|
|
allowVertexClaudeFallback: Bool = false,
|
|
codexHomePath: String? = nil,
|
|
historyDays: Int = 30,
|
|
refreshPricingInBackground: Bool = true,
|
|
bypassScannerDebounce: Bool = false,
|
|
scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil,
|
|
piScannerOptions overridePiScannerOptions: PiSessionCostScanner
|
|
.Options? = nil,
|
|
modelsDevClient: ModelsDevClient = ModelsDevClient(),
|
|
retryUnknownPricing: Bool = true) async throws -> CostUsageTokenSnapshot
|
|
{
|
|
guard provider == .codex || provider == .claude || provider == .vertexai || provider == .bedrock else {
|
|
throw CostUsageError.unsupportedProvider(provider)
|
|
}
|
|
|
|
let until = now
|
|
let clampedHistoryDays = max(1, min(365, historyDays))
|
|
// Rolling window is inclusive, so a 30-day display starts 29 days before `now`.
|
|
let since = Calendar.current.date(byAdding: .day, value: -(clampedHistoryDays - 1), to: now) ?? now
|
|
|
|
if provider == .bedrock {
|
|
let daily = try await Self.loadBedrockDailyReport(
|
|
environment: environment,
|
|
since: since,
|
|
until: until)
|
|
return Self.tokenSnapshot(
|
|
from: daily,
|
|
now: now,
|
|
historyDays: clampedHistoryDays,
|
|
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)
|
|
}
|
|
}
|
|
|
|
if provider == .vertexai {
|
|
options.claudeLogProviderFilter = allowVertexClaudeFallback ? .all : .vertexAIOnly
|
|
} else if provider == .claude {
|
|
options.claudeLogProviderFilter = .excludeVertexAI
|
|
}
|
|
if forceRefresh || bypassScannerDebounce {
|
|
options.refreshMinIntervalSeconds = 0
|
|
}
|
|
var resolvedPiOptions = overridePiScannerOptions ?? PiSessionCostScanner.Options()
|
|
if resolvedPiOptions.cacheRoot == nil {
|
|
resolvedPiOptions.cacheRoot = options.cacheRoot
|
|
}
|
|
if forceRefresh || bypassScannerDebounce {
|
|
resolvedPiOptions.refreshMinIntervalSeconds = 0
|
|
}
|
|
let piOptions = resolvedPiOptions
|
|
|
|
try Task.checkCancellation()
|
|
// The corpus scans below are synchronous and can run for minutes on large session
|
|
// archives. They execute on the dedicated scan queue so they never occupy a cooperative
|
|
// pool thread; CostUsageScanExecutor bridges this task's cancellation into the
|
|
// scanner-level checks.
|
|
let scanOptions = options
|
|
let scanResult = try await CostUsageScanExecutor.run { checkCancellation in
|
|
var daily = try CostUsageScanner.loadDailyReportCancellable(
|
|
provider: provider,
|
|
since: since,
|
|
until: until,
|
|
now: now,
|
|
options: scanOptions,
|
|
checkCancellation: checkCancellation)
|
|
try checkCancellation()
|
|
|
|
if provider == .vertexai,
|
|
!allowVertexClaudeFallback,
|
|
scanOptions.claudeLogProviderFilter == .vertexAIOnly,
|
|
daily.data.isEmpty
|
|
{
|
|
var fallback = scanOptions
|
|
fallback.claudeLogProviderFilter = .all
|
|
daily = try CostUsageScanner.loadDailyReportCancellable(
|
|
provider: provider,
|
|
since: since,
|
|
until: until,
|
|
now: now,
|
|
options: fallback,
|
|
checkCancellation: checkCancellation)
|
|
try checkCancellation()
|
|
}
|
|
|
|
var projects: [CostUsageProjectBreakdown] = []
|
|
var piDaily: CostUsageDailyReport?
|
|
if provider == .codex {
|
|
let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: scanOptions.cacheRoot)
|
|
projects = CostUsageScanner.buildCodexProjectBreakdownsFromCache(
|
|
cache: cache,
|
|
range: CostUsageScanner.CostUsageDayRange(since: since, until: until),
|
|
modelsDevCacheRoot: scanOptions.cacheRoot)
|
|
}
|
|
if provider == .codex || provider == .claude {
|
|
let piReport = try PiSessionCostScanner.loadDailyReportCancellable(
|
|
provider: provider,
|
|
since: since,
|
|
until: until,
|
|
now: now,
|
|
options: piOptions,
|
|
checkCancellation: checkCancellation)
|
|
try checkCancellation()
|
|
if provider == .codex {
|
|
piDaily = piReport
|
|
}
|
|
daily = CostUsageDailyReport.merged([daily, piReport])
|
|
}
|
|
if provider == .codex {
|
|
projects = Self.mergedProjectBreakdowns(
|
|
projects + [piDaily.flatMap(Self.unknownProjectBreakdown(from:))].compactMap(\.self))
|
|
}
|
|
return (daily: daily, projects: projects)
|
|
}
|
|
|
|
if retryUnknownPricing,
|
|
let request = Self.unknownPricingRefreshRequest(
|
|
provider: provider,
|
|
daily: scanResult.daily,
|
|
now: now,
|
|
cacheRoot: options.cacheRoot,
|
|
client: modelsDevClient),
|
|
await Self.refreshUnknownPricingIfNeeded(request, inBackground: refreshPricingInBackground)
|
|
{
|
|
return try await self.loadTokenSnapshot(
|
|
provider: provider,
|
|
environment: environment,
|
|
now: now,
|
|
forceRefresh: forceRefresh,
|
|
allowVertexClaudeFallback: allowVertexClaudeFallback,
|
|
codexHomePath: codexHomePath,
|
|
historyDays: historyDays,
|
|
refreshPricingInBackground: false,
|
|
scannerOptions: options,
|
|
piScannerOptions: piOptions,
|
|
modelsDevClient: modelsDevClient,
|
|
retryUnknownPricing: false)
|
|
}
|
|
|
|
return Self.tokenSnapshot(
|
|
from: scanResult.daily,
|
|
now: now,
|
|
historyDays: clampedHistoryDays,
|
|
projects: scanResult.projects)
|
|
}
|
|
|
|
private struct UnknownPricingRefreshRequest: Sendable {
|
|
let providerID: String
|
|
let modelIDs: Set<String>
|
|
let now: Date
|
|
let cacheRoot: URL?
|
|
let client: ModelsDevClient
|
|
}
|
|
|
|
private static func unknownPricingRefreshRequest(
|
|
provider: UsageProvider,
|
|
daily: CostUsageDailyReport,
|
|
now: Date,
|
|
cacheRoot: URL?,
|
|
client: ModelsDevClient) -> UnknownPricingRefreshRequest?
|
|
{
|
|
guard provider == .codex || provider == .claude else { return nil }
|
|
let unknownModelIDs = Set(daily.data.flatMap { entry in
|
|
entry.modelBreakdowns?.compactMap { breakdown -> String? in
|
|
guard breakdown.costUSD == nil else { return nil }
|
|
if provider == .codex,
|
|
CostUsagePricing.isCodexUnattributedModel(breakdown.modelName)
|
|
{
|
|
return nil
|
|
}
|
|
return breakdown.modelName
|
|
} ?? []
|
|
})
|
|
guard !unknownModelIDs.isEmpty else { return nil }
|
|
|
|
return UnknownPricingRefreshRequest(
|
|
providerID: provider == .codex ? "openai" : "anthropic",
|
|
modelIDs: unknownModelIDs,
|
|
now: now,
|
|
cacheRoot: cacheRoot,
|
|
client: client)
|
|
}
|
|
|
|
private static func refreshUnknownPricingIfNeeded(
|
|
_ request: UnknownPricingRefreshRequest,
|
|
inBackground: Bool) async -> Bool
|
|
{
|
|
if inBackground {
|
|
Task.detached(priority: .utility) {
|
|
_ = await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded(
|
|
providerID: request.providerID,
|
|
modelIDs: request.modelIDs,
|
|
now: request.now,
|
|
cacheRoot: request.cacheRoot,
|
|
client: request.client)
|
|
}
|
|
return false
|
|
}
|
|
return await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded(
|
|
providerID: request.providerID,
|
|
modelIDs: request.modelIDs,
|
|
now: request.now,
|
|
cacheRoot: request.cacheRoot,
|
|
client: request.client) == .pricingAvailable
|
|
}
|
|
|
|
static func loadCachedCodexTokenSnapshot(
|
|
now: Date = Date(),
|
|
codexHomePath: String? = nil,
|
|
historyDays: Int = 30,
|
|
scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil) async -> CostUsageTokenSnapshot?
|
|
{
|
|
await self.loadCachedCodexTokenSnapshotResult(
|
|
now: now,
|
|
codexHomePath: codexHomePath,
|
|
historyDays: historyDays,
|
|
scannerOptions: overrideScannerOptions)?.snapshot
|
|
}
|
|
|
|
static func loadCachedCodexTokenSnapshotResult(
|
|
now: Date = Date(),
|
|
codexHomePath: String? = nil,
|
|
historyDays: Int = 30,
|
|
scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil) async
|
|
-> CachedCodexTokenSnapshotResult?
|
|
{
|
|
if let codexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!codexHomePath.isEmpty
|
|
{
|
|
return nil
|
|
}
|
|
|
|
// Decoding the persisted scan cache parses multi-megabyte JSON; keep it off the
|
|
// cooperative pool alongside the scans themselves.
|
|
let cachedSnapshot: CachedCodexTokenSnapshotResult?? = try? await CostUsageScanExecutor.run { _ in
|
|
let clampedHistoryDays = max(1, min(365, historyDays))
|
|
let until = now
|
|
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)
|
|
var reports: [CostUsageDailyReport] = []
|
|
var projects: [CostUsageProjectBreakdown] = []
|
|
// 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?
|
|
var scanTimes: [Date] = []
|
|
var piMerged = false
|
|
|
|
if !cache.days.isEmpty,
|
|
cache.roots == CostUsageScanner.codexRootsFingerprint(options: options),
|
|
!CostUsageScanner.requestedWindowExpandsCache(range: range, cache: cache)
|
|
{
|
|
let daily = CostUsageScanner.buildCodexReportFromCache(
|
|
cache: cache,
|
|
range: range,
|
|
modelsDevCacheRoot: options.cacheRoot)
|
|
if !daily.data.isEmpty {
|
|
reports.append(daily)
|
|
if cache.lastScanUnixMs > 0 {
|
|
let scanAt = Date(timeIntervalSince1970: TimeInterval(cache.lastScanUnixMs) / 1000)
|
|
nativeScanAt = scanAt
|
|
scanTimes.append(scanAt)
|
|
}
|
|
if cache.codexProjectMetadataVersion == CostUsageScanner.codexProjectMetadataVersion {
|
|
projects.append(contentsOf: CostUsageScanner.buildCodexProjectBreakdownsFromCache(
|
|
cache: cache,
|
|
range: range,
|
|
modelsDevCacheRoot: options.cacheRoot))
|
|
}
|
|
}
|
|
}
|
|
|
|
if let piResult = PiSessionCostScanner.loadCachedDailyReportResult(
|
|
provider: .codex,
|
|
since: since,
|
|
until: until,
|
|
now: now,
|
|
cacheRoot: options.cacheRoot)
|
|
{
|
|
reports.append(piResult.report)
|
|
piMerged = true
|
|
if let piLastScanAt = piResult.lastScanAt {
|
|
scanTimes.append(piLastScanAt)
|
|
}
|
|
if let piProject = Self.unknownProjectBreakdown(from: piResult.report) {
|
|
projects.append(piProject)
|
|
}
|
|
}
|
|
|
|
guard !reports.isEmpty else { return nil }
|
|
// updatedAt keeps the caches' real (oldest) scan time; stamping the hydration time
|
|
// would let stale token rows inherit app-start freshness (#1964). lastRefreshAt
|
|
// drives TTL suppression and stays native-only: a merged load must never delay a
|
|
// rescan on the strength of another source's scan.
|
|
return CachedCodexTokenSnapshotResult(
|
|
snapshot: Self.tokenSnapshot(
|
|
from: CostUsageDailyReport.merged(reports),
|
|
now: now,
|
|
historyDays: clampedHistoryDays,
|
|
projects: Self.mergedProjectBreakdowns(projects),
|
|
updatedAt: scanTimes.min()),
|
|
lastRefreshAt: piMerged ? nil : nativeScanAt)
|
|
}
|
|
return cachedSnapshot.flatMap(\.self)
|
|
}
|
|
|
|
private static func loadBedrockDailyReport(
|
|
environment: [String: String],
|
|
since: Date,
|
|
until: Date) async throws -> CostUsageDailyReport
|
|
{
|
|
let resolved = try await BedrockCredentialResolver.resolve(environment: environment)
|
|
return try await BedrockUsageFetcher.fetchDailyReport(
|
|
credentials: resolved.credentials,
|
|
since: since,
|
|
until: until,
|
|
environment: environment)
|
|
}
|
|
|
|
static func tokenSnapshot(
|
|
from daily: CostUsageDailyReport,
|
|
now: Date,
|
|
historyDays: Int = 30,
|
|
useCurrentLocalDayForSession: Bool = true,
|
|
projects: [CostUsageProjectBreakdown] = [],
|
|
updatedAt: Date? = nil) -> CostUsageTokenSnapshot
|
|
{
|
|
let sessionEntry = useCurrentLocalDayForSession
|
|
? CostUsageTokenSnapshot.entry(in: daily.data, forLocalDayContaining: now)
|
|
: CostUsageTokenSnapshot.latestEntry(in: daily.data)
|
|
let hasHistoricalRows = !daily.data.isEmpty
|
|
let sessionTokens: Int? = if let sessionEntry {
|
|
sessionEntry.totalTokens
|
|
} else if hasHistoricalRows {
|
|
0
|
|
} else {
|
|
nil
|
|
}
|
|
let sessionCostUSD: Double? = if let sessionEntry {
|
|
sessionEntry.costUSD
|
|
} else if hasHistoricalRows {
|
|
0
|
|
} else {
|
|
nil
|
|
}
|
|
// Prefer summary totals when present; fall back to summing daily entries.
|
|
let totalFromSummary = daily.summary?.totalCostUSD
|
|
let totalFromEntries = daily.data.compactMap(\.costUSD).reduce(0, +)
|
|
let last30DaysCostUSD = totalFromSummary ?? (totalFromEntries > 0 ? totalFromEntries : nil)
|
|
let totalTokensFromSummary = daily.summary?.totalTokens
|
|
let totalTokensFromEntries = daily.data.compactMap(\.totalTokens).reduce(0, +)
|
|
let last30DaysTokens = totalTokensFromSummary ?? (totalTokensFromEntries > 0 ? totalTokensFromEntries : nil)
|
|
|
|
return CostUsageTokenSnapshot(
|
|
sessionTokens: sessionTokens,
|
|
sessionCostUSD: sessionCostUSD,
|
|
last30DaysTokens: last30DaysTokens,
|
|
last30DaysCostUSD: last30DaysCostUSD,
|
|
historyDays: historyDays,
|
|
daily: daily.data,
|
|
projects: projects,
|
|
updatedAt: updatedAt ?? now)
|
|
}
|
|
|
|
private static func unknownProjectBreakdown(from daily: CostUsageDailyReport) -> CostUsageProjectBreakdown? {
|
|
guard !daily.data.isEmpty else { return nil }
|
|
return CostUsageProjectBreakdown(
|
|
name: CostUsageProjectBreakdown.unknownProjectName,
|
|
path: nil,
|
|
totalTokens: daily.summary?.totalTokens,
|
|
totalCostUSD: daily.summary?.totalCostUSD,
|
|
daily: daily.data,
|
|
modelBreakdowns: self.projectModelBreakdowns(from: daily.data),
|
|
sources: [
|
|
CostUsageProjectSourceBreakdown(
|
|
name: CostUsageProjectBreakdown.unknownProjectName,
|
|
path: nil,
|
|
totalTokens: daily.summary?.totalTokens,
|
|
totalCostUSD: daily.summary?.totalCostUSD,
|
|
daily: daily.data,
|
|
modelBreakdowns: self.projectModelBreakdowns(from: daily.data)),
|
|
])
|
|
}
|
|
|
|
private static func mergedProjectBreakdowns(
|
|
_ projects: [CostUsageProjectBreakdown]) -> [CostUsageProjectBreakdown]
|
|
{
|
|
var dailyByPath: [String: [CostUsageDailyReport]] = [:]
|
|
var namesByPath: [String: String] = [:]
|
|
var sourceDailyByProjectPath: [String: [String: [CostUsageDailyReport]]] = [:]
|
|
var sourceNamesByProjectPath: [String: [String: String]] = [:]
|
|
for project in projects {
|
|
let key = project.path ?? ""
|
|
namesByPath[key] = project.name
|
|
dailyByPath[key, default: []].append(CostUsageDailyReport(data: project.daily, summary: nil))
|
|
let sources = project.sources.isEmpty
|
|
? [
|
|
CostUsageProjectSourceBreakdown(
|
|
name: project.name,
|
|
path: project.path,
|
|
totalTokens: project.totalTokens,
|
|
totalCostUSD: project.totalCostUSD,
|
|
daily: project.daily,
|
|
modelBreakdowns: project.modelBreakdowns),
|
|
]
|
|
: project.sources
|
|
for source in sources {
|
|
let sourceKey = source.path ?? ""
|
|
sourceNamesByProjectPath[key, default: [:]][sourceKey] = source.name
|
|
sourceDailyByProjectPath[key, default: [:]][sourceKey, default: []]
|
|
.append(CostUsageDailyReport(data: source.daily, summary: nil))
|
|
}
|
|
}
|
|
return dailyByPath.map { key, reports in
|
|
let merged = CostUsageDailyReport.merged(reports)
|
|
return CostUsageProjectBreakdown(
|
|
name: namesByPath[key] ?? CostUsageProjectBreakdown.unknownProjectName,
|
|
path: key.isEmpty ? nil : key,
|
|
totalTokens: merged.summary?.totalTokens,
|
|
totalCostUSD: merged.summary?.totalCostUSD,
|
|
daily: merged.data,
|
|
modelBreakdowns: Self.projectModelBreakdowns(from: merged.data),
|
|
sources: Self.mergedProjectSources(
|
|
sourceDailyByPath: sourceDailyByProjectPath[key] ?? [:],
|
|
sourceNamesByPath: sourceNamesByProjectPath[key] ?? [:]))
|
|
}
|
|
.sorted { lhs, rhs in
|
|
let lhsCost = lhs.totalCostUSD ?? -1
|
|
let rhsCost = rhs.totalCostUSD ?? -1
|
|
if lhsCost != rhsCost {
|
|
return lhsCost > rhsCost
|
|
}
|
|
let lhsTokens = lhs.totalTokens ?? -1
|
|
let rhsTokens = rhs.totalTokens ?? -1
|
|
if lhsTokens != rhsTokens {
|
|
return lhsTokens > rhsTokens
|
|
}
|
|
return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
|
|
}
|
|
}
|
|
|
|
private static func mergedProjectSources(
|
|
sourceDailyByPath: [String: [CostUsageDailyReport]],
|
|
sourceNamesByPath: [String: String]) -> [CostUsageProjectSourceBreakdown]
|
|
{
|
|
sourceDailyByPath.map { key, reports in
|
|
let merged = CostUsageDailyReport.merged(reports)
|
|
return CostUsageProjectSourceBreakdown(
|
|
name: sourceNamesByPath[key] ?? CostUsageProjectBreakdown.unknownProjectName,
|
|
path: key.isEmpty ? nil : key,
|
|
totalTokens: merged.summary?.totalTokens,
|
|
totalCostUSD: merged.summary?.totalCostUSD,
|
|
daily: merged.data,
|
|
modelBreakdowns: Self.projectModelBreakdowns(from: merged.data))
|
|
}
|
|
.sorted { lhs, rhs in
|
|
let lhsCost = lhs.totalCostUSD ?? -1
|
|
let rhsCost = rhs.totalCostUSD ?? -1
|
|
if lhsCost != rhsCost {
|
|
return lhsCost > rhsCost
|
|
}
|
|
let lhsTokens = lhs.totalTokens ?? -1
|
|
let rhsTokens = rhs.totalTokens ?? -1
|
|
if lhsTokens != rhsTokens {
|
|
return lhsTokens > rhsTokens
|
|
}
|
|
return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
|
|
}
|
|
}
|
|
|
|
private struct ProjectBreakdownAccumulator {
|
|
var totalTokens = 0
|
|
var sawTotalTokens = false
|
|
var costUSD: Double = 0
|
|
var sawCost = false
|
|
|
|
mutating func add(_ breakdown: CostUsageDailyReport.ModelBreakdown) {
|
|
if let totalTokens = breakdown.totalTokens {
|
|
self.totalTokens += totalTokens
|
|
self.sawTotalTokens = true
|
|
}
|
|
if let costUSD = breakdown.costUSD {
|
|
self.costUSD += costUSD
|
|
self.sawCost = true
|
|
}
|
|
}
|
|
|
|
func build(modelName: String) -> CostUsageDailyReport.ModelBreakdown {
|
|
CostUsageDailyReport.ModelBreakdown(
|
|
modelName: modelName,
|
|
costUSD: self.sawCost ? self.costUSD : nil,
|
|
totalTokens: self.sawTotalTokens ? self.totalTokens : nil)
|
|
}
|
|
}
|
|
|
|
private static func projectModelBreakdowns(
|
|
from entries: [CostUsageDailyReport.Entry]) -> [CostUsageDailyReport.ModelBreakdown]?
|
|
{
|
|
var accumulators: [String: ProjectBreakdownAccumulator] = [:]
|
|
for entry in entries {
|
|
for breakdown in entry.modelBreakdowns ?? [] {
|
|
var accumulator = accumulators[breakdown.modelName] ?? ProjectBreakdownAccumulator()
|
|
accumulator.add(breakdown)
|
|
accumulators[breakdown.modelName] = accumulator
|
|
}
|
|
}
|
|
guard !accumulators.isEmpty else { return nil }
|
|
return accumulators.map { modelName, accumulator in
|
|
accumulator.build(modelName: modelName)
|
|
}
|
|
.sorted { lhs, rhs in
|
|
let lhsCost = lhs.costUSD ?? -1
|
|
let rhsCost = rhs.costUSD ?? -1
|
|
if lhsCost != rhsCost {
|
|
return lhsCost > rhsCost
|
|
}
|
|
let lhsTokens = lhs.totalTokens ?? -1
|
|
let rhsTokens = rhs.totalTokens ?? -1
|
|
if lhsTokens != rhsTokens {
|
|
return lhsTokens > rhsTokens
|
|
}
|
|
return lhs.modelName > rhs.modelName
|
|
}
|
|
}
|
|
|
|
static func selectCurrentSession(from sessions: [CostUsageSessionReport.Entry])
|
|
-> CostUsageSessionReport.Entry?
|
|
{
|
|
if sessions.isEmpty {
|
|
return nil
|
|
}
|
|
return sessions.max { lhs, rhs in
|
|
let lDate = CostUsageDateParser.parse(lhs.lastActivity) ?? .distantPast
|
|
let rDate = CostUsageDateParser.parse(rhs.lastActivity) ?? .distantPast
|
|
if lDate != rDate {
|
|
return lDate < rDate
|
|
}
|
|
let lCost = lhs.costUSD ?? -1
|
|
let rCost = rhs.costUSD ?? -1
|
|
if lCost != rCost {
|
|
return lCost < rCost
|
|
}
|
|
let lTokens = lhs.totalTokens ?? -1
|
|
let rTokens = rhs.totalTokens ?? -1
|
|
if lTokens != rTokens {
|
|
return lTokens < rTokens
|
|
}
|
|
return lhs.session < rhs.session
|
|
}
|
|
}
|
|
|
|
static func selectMostRecentMonth(from months: [CostUsageMonthlyReport.Entry])
|
|
-> CostUsageMonthlyReport.Entry?
|
|
{
|
|
if months.isEmpty {
|
|
return nil
|
|
}
|
|
return months.max { lhs, rhs in
|
|
let lDate = CostUsageDateParser.parseMonth(lhs.month) ?? .distantPast
|
|
let rDate = CostUsageDateParser.parseMonth(rhs.month) ?? .distantPast
|
|
if lDate != rDate {
|
|
return lDate < rDate
|
|
}
|
|
let lCost = lhs.costUSD ?? -1
|
|
let rCost = rhs.costUSD ?? -1
|
|
if lCost != rCost {
|
|
return lCost < rCost
|
|
}
|
|
let lTokens = lhs.totalTokens ?? -1
|
|
let rTokens = rhs.totalTokens ?? -1
|
|
if lTokens != rTokens {
|
|
return lTokens < rTokens
|
|
}
|
|
return lhs.month < rhs.month
|
|
}
|
|
}
|
|
}
|