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
58 changes: 43 additions & 15 deletions Sources/CodexBarCore/CodexLocalProjectUsageIndexer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,18 @@ enum CodexLocalProjectUsageIndexer {
defer { CodexModelsTelemetry.end("IndexRefresh", id: refreshSignpost) }
let clampedHistoryDays = max(1, min(365, historyDays))
let until = now
let since = Calendar.current.date(byAdding: .day, value: -(clampedHistoryDays - 1), to: now) ?? now
let comparisonSince = self.modelsAnalyticsScanStart(since: since, until: until)
var scannerOptions = options.scannerOptions
if forceRefresh {
scannerOptions.refreshMinIntervalSeconds = 0
}
let since = scannerOptions.calendar.date(
byAdding: .day,
value: -(clampedHistoryDays - 1),
to: now) ?? now
let comparisonSince = self.modelsAnalyticsScanStart(
since: since,
until: until,
calendar: scannerOptions.calendar)

progress?(CodexLocalProjectUsageIndexProgress(phase: .scanningLogs))
_ = try CostUsageScanner.loadDailyReportCancellable(
Expand All @@ -63,7 +69,10 @@ enum CodexLocalProjectUsageIndexer {
checkCancellation: checkCancellation)
try checkCancellation?()

let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: scannerOptions.cacheRoot)
let cache = CostUsageCacheIO.load(
provider: .codex,
cacheRoot: scannerOptions.cacheRoot,
calendar: scannerOptions.calendar)
let catalogResult = CodexThreadCatalogReader.loadResult(options: scannerOptions)
let catalog = catalogResult.catalog
let sourceStatus = CodexLocalProjectUsageSourceStatus(catalog: catalogResult.completeness)
Expand Down Expand Up @@ -145,8 +154,14 @@ enum CodexLocalProjectUsageIndexer {
checkCancellation: CostUsageScanner.CancellationCheck? = nil) throws -> CodexLocalProjectUsageSnapshot
{
let clampedHistoryDays = max(1, min(365, historyDays))
let range = CostUsageScanner.CostUsageDayRange(since: since, until: until)
let cache = cacheOverride ?? CostUsageCacheIO.load(provider: .codex, cacheRoot: options.cacheRoot)
let range = CostUsageScanner.CostUsageDayRange(
since: since,
until: until,
calendar: options.calendar)
let cache = cacheOverride ?? CostUsageCacheIO.load(
provider: .codex,
cacheRoot: options.cacheRoot,
calendar: options.calendar)
let catalog = catalogOverride ?? CodexThreadCatalogReader.load(options: options)
let expectedRoots = CostUsageScanner.codexRootsFingerprint(options: options)
let scopeSignature = self.stableScopeSignature(options: options)
Expand Down Expand Up @@ -275,7 +290,8 @@ enum CodexLocalProjectUsageIndexer {
since: since,
until: until,
historyDays: clampedHistoryDays,
generatedAt: now),
generatedAt: now,
calendar: options.calendar),
identity: ModelsAnalyticsIdentity(
scopeSignature: scopeSignature,
rootsFingerprint: rootsFingerprint),
Expand All @@ -297,6 +313,7 @@ extension CodexLocalProjectUsageIndexer {
let until: Date
let historyDays: Int
let generatedAt: Date
let calendar: Calendar
}

fileprivate struct ModelsAnalyticsIdentity {
Expand Down Expand Up @@ -653,19 +670,27 @@ extension CodexLocalProjectUsageIndexer {
{
let aggregationSignpost = CodexModelsTelemetry.begin("SnapshotAggregation")
defer { CodexModelsTelemetry.end("SnapshotAggregation", id: aggregationSignpost) }
let periods = self.modelsAnalyticsPeriods(since: window.since, until: window.until)
let periods = self.modelsAnalyticsPeriods(
since: window.since,
until: window.until,
calendar: window.calendar)
let previousInterval = periods.previous
let previousRange = CostUsageScanner.CostUsageDayRange(
since: previousInterval.start,
until: previousInterval.end.addingTimeInterval(-1))
until: previousInterval.end.addingTimeInterval(-1),
calendar: window.calendar)
let previousBuckets = try self.sessionBuckets(
from: context.cache,
range: previousRange,
catalog: context.catalog,
progress: nil,
checkCancellation: checkCancellation).sessionBuckets
let currentFragments = self.analyticsFragments(from: context.currentBuckets.values)
let previousFragments = self.analyticsFragments(from: previousBuckets.values)
let currentFragments = self.analyticsFragments(
from: context.currentBuckets.values,
calendar: window.calendar)
let previousFragments = self.analyticsFragments(
from: previousBuckets.values,
calendar: window.calendar)
let currentBucketsByProject = Dictionary(grouping: context.currentBuckets.values, by: \.projectId)
let previousBucketsByProject = Dictionary(grouping: previousBuckets.values, by: \.projectId)
let currentFragmentsByProject = Dictionary(grouping: currentFragments, by: \.workspaceID)
Expand Down Expand Up @@ -814,14 +839,16 @@ extension CodexLocalProjectUsageIndexer {
}

fileprivate static func analyticsFragments(
from buckets: Dictionary<String, SessionBucket>.Values) -> [CodexModelsUsageFragment]
from buckets: Dictionary<String, SessionBucket>.Values,
calendar: Calendar) -> [CodexModelsUsageFragment]
{
buckets.flatMap { bucket in
let calendar = CostUsageScanner.CostUsageDayRange.localGregorianCalendar(matching: calendar)
return buckets.flatMap { bucket in
if bucket.hasCompleteEventRows, !bucket.usageRows.isEmpty {
return bucket.usageRows.compactMap { row -> CodexModelsUsageFragment? in
guard let timestampUnixMs = row.timestampUnixMs else { return nil }
let timestamp = Date(timeIntervalSince1970: Double(timestampUnixMs) / 1000)
let day = Calendar.current.startOfDay(for: timestamp)
let day = calendar.startOfDay(for: timestamp)
let inputTokens = max(0, row.input)
let outputTokens = max(0, row.output)
let totalTokens = Int64(inputTokens + outputTokens)
Expand All @@ -841,7 +868,8 @@ extension CodexLocalProjectUsageIndexer {
}
}
return bucket.modelDailyTotals.flatMap { day, models -> [CodexModelsUsageFragment] in
guard let date = CostUsageDateParser.parse(day) else { return [] }
guard let parsedDate = CostUsageScanner.parseDayKey(day, calendar: calendar) else { return [] }
let date = calendar.startOfDay(for: parsedDate)
return models.map { model, totals in
CodexModelsUsageFragment(
workspaceID: bucket.projectId,
Expand Down Expand Up @@ -895,7 +923,7 @@ extension CodexLocalProjectUsageIndexer {
}

fileprivate static func stableScopeSignature(options: CostUsageScanner.Options) -> String {
CodexLocalDataScope.resolve(options: options).identifier
"\(CodexLocalDataScope.resolve(options: options).identifier)|timeZone=\(options.calendar.timeZone.identifier)"
}

fileprivate static func rootsFingerprint(_ roots: [String: Int64]) -> [String: Int64] {
Expand Down
38 changes: 25 additions & 13 deletions Sources/CodexBarCore/CostUsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -233,10 +233,7 @@ public struct CostUsageFetcher: Sendable {
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 let remoteSnapshot = try await self.loadRemoteTokenSnapshot(
provider: provider,
Expand All @@ -252,6 +249,8 @@ public struct CostUsageFetcher: Sendable {
overrideScannerOptions,
provider: provider,
codexHomePath: codexHomePath)
// 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)
let shouldMergePiUsage = provider != .codex || scopedCodexHomePath?.isEmpty != false
await Self.refreshPricingIfAllowed(
Expand All @@ -276,6 +275,7 @@ public struct CostUsageFetcher: Sendable {
if resolvedPiOptions.cacheRoot == nil {
resolvedPiOptions.cacheRoot = options.cacheRoot
}
resolvedPiOptions.calendar = options.calendar
if forceRefresh || bypassScannerDebounce {
resolvedPiOptions.refreshMinIntervalSeconds = 0
}
Expand All @@ -291,7 +291,7 @@ public struct CostUsageFetcher: Sendable {
var daily = try CostUsageScanner.loadDailyReportCancellable(
provider: provider,
since: since,
until: until,
until: now,
now: now,
options: scanOptions,
checkCancellation: checkCancellation)
Expand All @@ -307,7 +307,7 @@ public struct CostUsageFetcher: Sendable {
daily = try CostUsageScanner.loadDailyReportCancellable(
provider: provider,
since: since,
until: until,
until: now,
now: now,
options: fallback,
checkCancellation: checkCancellation)
Expand All @@ -322,7 +322,8 @@ public struct CostUsageFetcher: Sendable {
let cache = CostUsageScanner.codexCache(
CostUsageCacheIO.load(provider: .codex, cacheRoot: scanOptions.cacheRoot),
scopedTo: roots)
let range = CostUsageScanner.CostUsageDayRange(since: since, until: until)
let range = CostUsageScanner.CostUsageDayRange(
since: since, until: now, calendar: scanOptions.calendar)
projects = CostUsageScanner.buildCodexProjectBreakdownsFromCache(
cache: cache,
range: range,
Expand All @@ -337,7 +338,7 @@ public struct CostUsageFetcher: Sendable {
let piReport = try PiSessionCostScanner.loadDailyReportCancellable(
provider: provider,
since: since,
until: until,
until: now,
now: now,
options: piOptions,
checkCancellation: checkCancellation)
Expand Down Expand Up @@ -389,6 +390,7 @@ public struct CostUsageFetcher: Sendable {
from: scanResult.daily,
now: now,
historyDays: clampedHistoryDays,
calendar: scanOptions.calendar,
projects: scanResult.projects,
sessions: scanResult.sessions)
}
Expand Down Expand Up @@ -510,10 +512,16 @@ public struct CostUsageFetcher: Sendable {
// 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 until = now
let since = options.calendar.date(
byAdding: .day,
value: -(clampedHistoryDays - 1),
to: now) ?? now
let range = CostUsageScanner.CostUsageDayRange(
since: since,
until: until,
calendar: options.calendar)
let roots = CostUsageScanner.codexSessionsRoots(options: options)
let cache = CostUsageScanner.codexCache(
CostUsageCacheIO.load(provider: .codex, cacheRoot: options.cacheRoot),
Expand All @@ -527,7 +535,8 @@ public struct CostUsageFetcher: Sendable {
var scanTimes: [Date] = []
var piMerged = false

if !cache.days.isEmpty,
if cache.timeZoneIdentifier == range.calendar.timeZone.identifier,
!cache.days.isEmpty,
cache.roots == CostUsageScanner.codexRootsFingerprint(options: options),
!CostUsageScanner.requestedWindowExpandsCache(range: range, cache: cache)
{
Expand Down Expand Up @@ -561,7 +570,8 @@ public struct CostUsageFetcher: Sendable {
since: since,
until: until,
now: now,
cacheRoot: options.cacheRoot)
cacheRoot: options.cacheRoot,
calendar: options.calendar)
{
reports.append(piResult.report)
piMerged = true
Expand All @@ -586,6 +596,7 @@ public struct CostUsageFetcher: Sendable {
from: CostUsageDailyReport.merged(reports),
now: now,
historyDays: clampedHistoryDays,
calendar: options.calendar,
projects: Self.mergedProjectBreakdowns(projects),
sessions: sessions,
updatedAt: scanTimes.min()),
Expand Down Expand Up @@ -738,6 +749,7 @@ public struct CostUsageFetcher: Sendable {
now: Date,
historyDays: Int = 30,
useCurrentLocalDayForSession: Bool = true,
calendar: Calendar = .current,
meteredCostUSD: Double? = nil,
credentialScopeFingerprint: String? = nil,
historyLabel: String? = nil,
Expand All @@ -746,7 +758,7 @@ public struct CostUsageFetcher: Sendable {
updatedAt: Date? = nil) -> CostUsageTokenSnapshot
{
let sessionEntry = useCurrentLocalDayForSession
? CostUsageTokenSnapshot.entry(in: daily.data, forLocalDayContaining: now)
? CostUsageTokenSnapshot.entry(in: daily.data, forLocalDayContaining: now, calendar: calendar)
: CostUsageTokenSnapshot.latestEntry(in: daily.data)
let hasHistoricalRows = !daily.data.isEmpty
let sessionTokens: Int? = if let sessionEntry {
Expand Down
7 changes: 7 additions & 0 deletions Sources/CodexBarCore/CostUsageModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1064,7 +1064,14 @@ enum CostUsageBucketInterval {
}

enum CostUsageLocalDay {
static func gregorianCalendar(matching calendar: Calendar = .current) -> Calendar {
var gregorian = Calendar(identifier: .gregorian)
gregorian.timeZone = calendar.timeZone
return gregorian
}

static func key(from date: Date, calendar: Calendar = .current) -> String {
let calendar = Self.gregorianCalendar(matching: calendar)
let components = calendar.dateComponents([.year, .month, .day], from: date)
let year = components.year ?? 0
let month = components.month ?? 0
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 = "6f689d90f8eedcbd"
static let value = "21dae5bee0a0ece1"
}
13 changes: 10 additions & 3 deletions Sources/CodexBarCore/PiSessionCostCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Foundation

enum PiSessionCostCacheIO {
/// Artifact schema version. Pricing changes are tracked separately by `pricingKey`.
private static let artifactVersion = 7
private static let artifactVersion = 8

private static func defaultCacheRoot() -> URL {
let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
Expand All @@ -27,11 +27,17 @@ enum PiSessionCostCacheIO {
return decoded
}

static func save(cache: PiSessionCostCache, cacheRoot: URL? = nil) {
static func save(
cache: PiSessionCostCache,
cacheRoot: URL? = nil,
calendar: Calendar = .current)
{
let url = self.cacheFileURL(cacheRoot: cacheRoot)
let dir = url.deletingLastPathComponent()
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)

var cache = cache
cache.timeZoneIdentifier = calendar.timeZone.identifier
let tmp = dir.appendingPathComponent(".tmp-\(UUID().uuidString).json", isDirectory: false)
let data = (try? JSONEncoder().encode(cache)) ?? Data()
do {
Expand All @@ -52,11 +58,12 @@ struct PiSessionCostCache: Codable {
var lastScanUnixMs: Int64 = 0
var scanSinceKey: String?
var scanUntilKey: String?
var timeZoneIdentifier: String?
var pricingKey: String?
var daysByProvider: [String: [String: [String: PiPackedUsage]]] = [:]
var files: [String: PiSessionFileUsage] = [:]

init(version: Int = 7) {
init(version: Int = 8) {
self.version = version
}
}
Expand Down
Loading