From 61c2ec9a9637969e753a260f92e83c6ecf89b015 Mon Sep 17 00:00:00 2001 From: Milan Mijatovic Date: Thu, 23 Jul 2026 16:28:13 +0700 Subject: [PATCH 1/3] Fix cost history dates for non-Gregorian calendars --- .../Generated/CodexParserHash.generated.swift | 2 +- Sources/CodexBarCore/PiSessionCostCache.swift | 4 +- .../CodexBarCore/PiSessionCostScanner.swift | 10 +++-- .../Vendored/CostUsage/CostUsageCache.swift | 2 +- .../CostUsageScanner+CacheHelpers.swift | 7 ++-- .../CostUsageScanner+CodexPriority.swift | 5 ++- .../CostUsageScanner+Timestamp.swift | 9 +++-- .../Vendored/CostUsage/CostUsageScanner.swift | 37 +++++++++++------- Tests/CodexBarTests/CostUsageCacheTests.swift | 10 ++--- .../CostUsageCalendarTests.swift | 39 +++++++++++++++++++ .../PiSessionCostScannerTests.swift | 6 +-- 11 files changed, 93 insertions(+), 38 deletions(-) create mode 100644 Tests/CodexBarTests/CostUsageCalendarTests.swift diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index d46bf4b5b2..7831183d0f 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "48ac20dad61e9a7f" + static let value = "29db9a049f588c3b" } diff --git a/Sources/CodexBarCore/PiSessionCostCache.swift b/Sources/CodexBarCore/PiSessionCostCache.swift index 01eaea3c6f..1e9f2fcc45 100644 --- a/Sources/CodexBarCore/PiSessionCostCache.swift +++ b/Sources/CodexBarCore/PiSessionCostCache.swift @@ -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! @@ -56,7 +56,7 @@ struct PiSessionCostCache: Codable { var daysByProvider: [String: [String: [String: PiPackedUsage]]] = [:] var files: [String: PiSessionFileUsage] = [:] - init(version: Int = 7) { + init(version: Int = 8) { self.version = version } } diff --git a/Sources/CodexBarCore/PiSessionCostScanner.swift b/Sources/CodexBarCore/PiSessionCostScanner.swift index 159eed0a52..de333da15d 100644 --- a/Sources/CodexBarCore/PiSessionCostScanner.swift +++ b/Sources/CodexBarCore/PiSessionCostScanner.swift @@ -1040,8 +1040,9 @@ extension PiSessionCostScanner { } private static func localMidnight(_ date: Date) -> Date { - let components = Calendar.current.dateComponents([.year, .month, .day], from: date) - return Calendar.current.date(from: components) ?? date + let calendar = CostUsageScanner.CostUsageDayRange.localGregorianCalendar() + let components = calendar.dateComponents([.year, .month, .day], from: date) + return calendar.date(from: components) ?? date } private static func dateFromDayKey(_ key: String) -> Date? { @@ -1051,9 +1052,10 @@ extension PiSessionCostScanner { let month = Int(parts[1]), let day = Int(parts[2]) else { return nil } + let calendar = CostUsageScanner.CostUsageDayRange.localGregorianCalendar() var components = DateComponents() - components.calendar = Calendar.current - components.timeZone = TimeZone.current + components.calendar = calendar + components.timeZone = calendar.timeZone components.year = year components.month = month components.day = day diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index 7323a07c43..de20fe83da 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -13,7 +13,7 @@ enum CostUsageCacheIO { case .codex: 10 case .claude, .vertexai: - 5 + 6 default: 1 } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift index 8bdbeec25d..bb707dd0f6 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift @@ -1512,7 +1512,7 @@ extension CostUsageScanner { } } - static func parseDayKey(_ key: String) -> Date? { + static func parseDayKey(_ key: String, calendar: Calendar = .current) -> Date? { let parts = key.split(separator: "-") guard parts.count == 3 else { return nil } guard @@ -1521,9 +1521,10 @@ extension CostUsageScanner { let day = Int(parts[2]) else { return nil } + let calendar = CostUsageDayRange.localGregorianCalendar(matching: calendar) var comps = DateComponents() - comps.calendar = Calendar.current - comps.timeZone = TimeZone.current + comps.calendar = calendar + comps.timeZone = calendar.timeZone comps.year = year comps.month = month comps.day = day diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift index 088af5b728..af24e5e88c 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift @@ -626,7 +626,8 @@ extension CostUsageScanner { private static func nextDayKey(after dayKey: String) -> String { guard let date = self.localDate(forDayKey: dayKey), - let next = Calendar.current.date(byAdding: .day, value: 1, to: date) + let next = CostUsageScanner.CostUsageDayRange.localGregorianCalendar() + .date(byAdding: .day, value: 1, to: date) else { return dayKey } return CostUsageScanner.CostUsageDayRange.dayKey(from: next) } @@ -644,7 +645,7 @@ extension CostUsageScanner { let day = Int(parts[2]) else { return nil } var components = DateComponents() - components.calendar = Calendar.current + components.calendar = CostUsageScanner.CostUsageDayRange.localGregorianCalendar() components.year = year components.month = month components.day = day diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Timestamp.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Timestamp.swift index b8bf32153c..ead9180405 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Timestamp.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Timestamp.swift @@ -30,7 +30,7 @@ extension CostUsageScanner { CostUsageTimestampParser.parseISO(text) } - static func dayKeyFromTimestamp(_ text: String) -> String? { + static func dayKeyFromTimestamp(_ text: String, calendar: Calendar = .current) -> String? { let bytes = Array(text.utf8) guard bytes.count >= 20 else { return nil } guard bytes[safe: 4] == 45, bytes[safe: 7] == 45 else { return nil } @@ -102,16 +102,17 @@ extension CostUsageScanner { comps.second = second guard let date = comps.date else { return nil } - let local = Calendar.current.dateComponents([.year, .month, .day], from: date) + let local = CostUsageDayRange.localGregorianCalendar(matching: calendar) + .dateComponents([.year, .month, .day], from: date) guard let localYear = local.year, let localMonth = local.month, let localDay = local.day else { return nil } return String(format: "%04d-%02d-%02d", localYear, localMonth, localDay) } - static func dayKeyFromParsedISO(_ text: String) -> String? { + static func dayKeyFromParsedISO(_ text: String, calendar: Calendar = .current) -> String? { guard let date = CostUsageTimestampParser.parseISO(text) else { return nil } - return CostUsageDayRange.dayKey(from: date) + return CostUsageDayRange.dayKey(from: date, calendar: calendar) } private static func parse2(_ bytes: [UInt8], at index: Int) -> Int? { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 7ff583f33d..4e9625aeea 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -843,16 +843,25 @@ enum CostUsageScanner { let scanSinceKey: String let scanUntilKey: String - init(since: Date, until: Date) { - self.sinceKey = Self.dayKey(from: since) - self.untilKey = Self.dayKey(from: until) - self.scanSinceKey = Self.dayKey(from: Calendar.current.date(byAdding: .day, value: -1, to: since) ?? since) - self.scanUntilKey = Self.dayKey(from: Calendar.current.date(byAdding: .day, value: 1, to: until) ?? until) + init(since: Date, until: Date, calendar: Calendar = .current) { + let calendar = Self.localGregorianCalendar(matching: calendar) + self.sinceKey = Self.dayKey(from: since, calendar: calendar) + self.untilKey = Self.dayKey(from: until, calendar: calendar) + let scanSince = calendar.date(byAdding: .day, value: -1, to: since) ?? since + let scanUntil = calendar.date(byAdding: .day, value: 1, to: until) ?? until + self.scanSinceKey = Self.dayKey(from: scanSince, calendar: calendar) + self.scanUntilKey = Self.dayKey(from: scanUntil, calendar: calendar) } - static func dayKey(from date: Date) -> String { - let cal = Calendar.current - let comps = cal.dateComponents([.year, .month, .day], from: date) + static func localGregorianCalendar(matching calendar: Calendar = .current) -> Calendar { + var gregorian = Calendar(identifier: .gregorian) + gregorian.timeZone = calendar.timeZone + return gregorian + } + + static func dayKey(from date: Date, calendar: Calendar = .current) -> String { + let calendar = Self.localGregorianCalendar(matching: calendar) + let comps = calendar.dateComponents([.year, .month, .day], from: date) let y = comps.year ?? 1970 let m = comps.month ?? 1 let d = comps.day ?? 1 @@ -1142,7 +1151,8 @@ enum CostUsageScanner { private static func dayKey(_ dayKey: String, addingDays days: Int) -> String? { guard let date = self.parseDayKey(dayKey) else { return nil } - guard let shifted = Calendar.current.date(byAdding: .day, value: days, to: date) else { return nil } + let calendar = CostUsageDayRange.localGregorianCalendar() + guard let shifted = calendar.date(byAdding: .day, value: days, to: date) else { return nil } return CostUsageDayRange.dayKey(from: shifted) } @@ -1153,7 +1163,7 @@ enum CostUsageScanner { var out: [String] = [] var cursor = since - let calendar = Calendar.current + let calendar = CostUsageDayRange.localGregorianCalendar() while CostUsageDayRange.dayKey(from: cursor) <= untilKey { out.append(CostUsageDayRange.dayKey(from: cursor)) guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break } @@ -1206,7 +1216,8 @@ enum CostUsageScanner { let untilDate = Self.parseDayKey(scanUntilKey) ?? date while date <= untilDate { - let comps = Calendar.current.dateComponents([.year, .month, .day], from: date) + let calendar = CostUsageDayRange.localGregorianCalendar() + let comps = calendar.dateComponents([.year, .month, .day], from: date) let y = String(format: "%04d", comps.year ?? 1970) let m = String(format: "%02d", comps.month ?? 1) let d = String(format: "%02d", comps.day ?? 1) @@ -1225,7 +1236,7 @@ enum CostUsageScanner { } } - date = Calendar.current.date(byAdding: .day, value: 1, to: date) ?? untilDate.addingTimeInterval(1) + date = calendar.date(byAdding: .day, value: 1, to: date) ?? untilDate.addingTimeInterval(1) } return out @@ -2853,7 +2864,7 @@ enum CostUsageScanner { let cachedUntilKey = cache.scanUntilKey let shouldRunColdCacheLookback = cache.files.isEmpty || plan.rootsChanged let coldCacheLookbackStart = Self.parseDayKey(range.scanSinceKey) - .map { Calendar.current.startOfDay(for: $0) } + .map { CostUsageDayRange.localGregorianCalendar().startOfDay(for: $0) } var seenPaths: Set = [] var files: [URL] = [] for root in plan.roots { diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index 5eee6fdc93..64be049984 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -12,8 +12,8 @@ struct CostUsageCacheTests { let vertexURL = CostUsageCacheIO.cacheFileURL(provider: .vertexai, cacheRoot: root) #expect(codexURL.lastPathComponent == "codex-v10.json") - #expect(claudeURL.lastPathComponent == "claude-v5.json") - #expect(vertexURL.lastPathComponent == "vertexai-v5.json") + #expect(claudeURL.lastPathComponent == "claude-v6.json") + #expect(vertexURL.lastPathComponent == "vertexai-v6.json") } @Test @@ -59,11 +59,11 @@ struct CostUsageCacheTests { let legacyURL = root .appendingPathComponent("cost-usage", isDirectory: true) - .appendingPathComponent("pi-sessions-v6.json", isDirectory: false) + .appendingPathComponent("pi-sessions-v7.json", isDirectory: false) try FileManager.default.createDirectory( at: legacyURL.deletingLastPathComponent(), withIntermediateDirectories: true) - var legacy = PiSessionCostCache(version: 6) + var legacy = PiSessionCostCache(version: 7) legacy.lastScanUnixMs = 999 legacy.files = [ "/tmp/session.jsonl": PiSessionFileUsage( @@ -77,7 +77,7 @@ struct CostUsageCacheTests { let loaded = PiSessionCostCacheIO.load(cacheRoot: root) - #expect(loaded.version == 7) + #expect(loaded.version == 8) #expect(loaded.lastScanUnixMs == 0) #expect(loaded.files.isEmpty) } diff --git a/Tests/CodexBarTests/CostUsageCalendarTests.swift b/Tests/CodexBarTests/CostUsageCalendarTests.swift new file mode 100644 index 0000000000..d6aa8f0dde --- /dev/null +++ b/Tests/CodexBarTests/CostUsageCalendarTests.swift @@ -0,0 +1,39 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CostUsageCalendarTests { + @Test + func `day keys remain Gregorian under a Buddhist calendar`() throws { + let bangkok = try #require(TimeZone(identifier: "Asia/Bangkok")) + var gregorian = Calendar(identifier: .gregorian) + gregorian.timeZone = bangkok + let date = try #require(gregorian.date(from: DateComponents( + timeZone: bangkok, + year: 2026, + month: 7, + day: 23, + hour: 12))) + + var buddhist = Calendar(identifier: .buddhist) + buddhist.timeZone = bangkok + #expect(buddhist.component(.year, from: date) == 2569) + + let range = CostUsageScanner.CostUsageDayRange(since: date, until: date, calendar: buddhist) + #expect(range.sinceKey == "2026-07-23") + #expect(range.untilKey == "2026-07-23") + #expect(range.scanSinceKey == "2026-07-22") + #expect(range.scanUntilKey == "2026-07-24") + #expect(CostUsageScanner.dayKeyFromTimestamp( + "2026-07-23T05:00:00Z", + calendar: buddhist) == "2026-07-23") + #expect(CostUsageScanner.dayKeyFromParsedISO( + "2026-07-23T05:00:00Z", + calendar: buddhist) == "2026-07-23") + + let parsed = try #require(CostUsageScanner.parseDayKey("2026-07-23", calendar: buddhist)) + #expect(CostUsageScanner.CostUsageDayRange.dayKey( + from: parsed, + calendar: buddhist) == "2026-07-23") + } +} diff --git a/Tests/CodexBarTests/PiSessionCostScannerTests.swift b/Tests/CodexBarTests/PiSessionCostScannerTests.swift index bb8814c382..677b807fbd 100644 --- a/Tests/CodexBarTests/PiSessionCostScannerTests.swift +++ b/Tests/CodexBarTests/PiSessionCostScannerTests.swift @@ -750,8 +750,8 @@ struct PiSessionCostScannerTests { #expect(FileManager.default.fileExists(atPath: newCacheURL.path)) let newCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) let rebuilt = newCache.daysByProvider[UsageProvider.codex.rawValue]?[dayKey]?[model] - #expect(newCacheURL.lastPathComponent == "pi-sessions-v7.json") - #expect(newCache.version == 7) + #expect(newCacheURL.lastPathComponent == "pi-sessions-v8.json") + #expect(newCache.version == 8) #expect(rebuilt?.usageSampleCount == 1) #expect(rebuilt?.costSampleCount == 1) #expect(rebuilt?.costNanos == Int64((expectedCost * 1_000_000_000).rounded())) @@ -859,7 +859,7 @@ struct PiSessionCostScannerTests { let newCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) let rebuilt = newCache.daysByProvider[UsageProvider.codex.rawValue]?[dayKey]?[model] - #expect(newCache.version == 7) + #expect(newCache.version == 8) #expect(rebuilt?.costNanos == Int64((expectedCost * 1_000_000_000).rounded())) } } From 8830e198d3a08cef7939e9f4fee0c749dfa609b3 Mon Sep 17 00:00:00 2001 From: Milan Mijatovic Date: Thu, 23 Jul 2026 17:38:00 +0700 Subject: [PATCH 2/3] Fix non-Gregorian cost usage regressions --- Sources/CodexBarCore/CostUsageModels.swift | 7 ++ .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageScanner.swift | 68 ++++++----- .../CostUsageCalendarTests.swift | 107 ++++++++++++++++++ .../CostUsageWindowSummaryTests.swift | 42 +++++++ 5 files changed, 198 insertions(+), 28 deletions(-) diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index d5b7849b0b..88f2760bbb 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -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 diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 7831183d0f..b074e92154 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "29db9a049f588c3b" + static let value = "7abccfd3729251f2" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 4e9625aeea..498bad4167 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -29,6 +29,7 @@ enum CostUsageScanner { var claudeProjectsRoots: [URL]? var cacheRoot: URL? var codexTraceDatabaseURL: URL? + var calendar: Calendar var refreshMinIntervalSeconds: TimeInterval = 60 var claudeLogProviderFilter: ClaudeLogProviderFilter = .all /// Force a full rescan, ignoring per-file cache and incremental offsets. @@ -39,6 +40,7 @@ enum CostUsageScanner { claudeProjectsRoots: [URL]? = nil, cacheRoot: URL? = nil, codexTraceDatabaseURL: URL? = nil, + calendar: Calendar = .current, claudeLogProviderFilter: ClaudeLogProviderFilter = .all, forceRescan: Bool = false) { @@ -46,6 +48,7 @@ enum CostUsageScanner { self.claudeProjectsRoots = claudeProjectsRoots self.cacheRoot = cacheRoot self.codexTraceDatabaseURL = codexTraceDatabaseURL + self.calendar = calendar self.claudeLogProviderFilter = claudeLogProviderFilter self.forceRescan = forceRescan } @@ -795,7 +798,7 @@ enum CostUsageScanner { options: Options = Options(), checkCancellation: CancellationCheck?) throws -> CostUsageDailyReport { - let range = CostUsageDayRange(since: since, until: until) + let range = CostUsageDayRange(since: since, until: until, calendar: options.calendar) let emptyReport = CostUsageDailyReport(data: [], summary: nil) try checkCancellation?() @@ -854,18 +857,11 @@ enum CostUsageScanner { } static func localGregorianCalendar(matching calendar: Calendar = .current) -> Calendar { - var gregorian = Calendar(identifier: .gregorian) - gregorian.timeZone = calendar.timeZone - return gregorian + CostUsageLocalDay.gregorianCalendar(matching: calendar) } static func dayKey(from date: Date, calendar: Calendar = .current) -> String { - let calendar = Self.localGregorianCalendar(matching: calendar) - let comps = calendar.dateComponents([.year, .month, .day], from: date) - let y = comps.year ?? 1970 - let m = comps.month ?? 1 - let d = comps.day ?? 1 - return String(format: "%04d-%02d-%02d", y, m, d) + CostUsageLocalDay.key(from: date, calendar: calendar) } static func isInRange(dayKey: String, since: String, until: String) -> Bool { @@ -913,12 +909,14 @@ enum CostUsageScanner { root: URL, scanSinceKey: String, scanUntilKey: String, - includeRecursive: Bool) -> [URL] + includeRecursive: Bool, + calendar: Calendar = .current) -> [URL] { let partitioned = self.listCodexSessionFilesByDatePartition( root: root, scanSinceKey: scanSinceKey, - scanUntilKey: scanUntilKey) + scanUntilKey: scanUntilKey, + calendar: calendar) let flat = self.listCodexSessionFilesFlat(root: root, scanSinceKey: scanSinceKey, scanUntilKey: scanUntilKey) let recursive = includeRecursive ? self.listCodexLegacySessionFilesRecursive(root: root) : [] var seen: Set = [] @@ -1116,14 +1114,19 @@ enum CostUsageScanner { root: URL, scanSinceKey: String, scanUntilKey: String, - modifiedSince: Date) -> [URL] + modifiedSince: Date, + calendar: Calendar = .current) -> [URL] { - let lookbackSinceKey = self.dayKey(scanSinceKey, addingDays: -self.codexActiveSessionLookbackDays) + let lookbackSinceKey = self.dayKey( + scanSinceKey, + addingDays: -self.codexActiveSessionLookbackDays, + calendar: calendar) ?? scanSinceKey let partitioned = self.listCodexSessionFilesByDatePartition( root: root, scanSinceKey: lookbackSinceKey, - scanUntilKey: scanUntilKey) + scanUntilKey: scanUntilKey, + calendar: calendar) let partitionedModified = self.filterRecentlyModified(files: partitioned, modifiedSince: modifiedSince) let legacyRecursive = self.listCodexRecentlyModifiedFilesRecursive(root: root, modifiedSince: modifiedSince) @@ -1149,11 +1152,20 @@ enum CostUsageScanner { value.count == length && value.allSatisfy(\.isNumber) } - private static func dayKey(_ dayKey: String, addingDays days: Int) -> String? { - guard let date = self.parseDayKey(dayKey) else { return nil } - let calendar = CostUsageDayRange.localGregorianCalendar() + private static func dayKey( + _ dayKey: String, + addingDays days: Int, + calendar: Calendar = .current) -> String? + { + let calendar = CostUsageDayRange.localGregorianCalendar(matching: calendar) + guard let date = self.parseDayKey(dayKey, calendar: calendar) else { return nil } guard let shifted = calendar.date(byAdding: .day, value: days, to: date) else { return nil } - return CostUsageDayRange.dayKey(from: shifted) + return CostUsageDayRange.dayKey(from: shifted, calendar: calendar) + } + + private static func localStartOfDay(_ dayKey: String, calendar: Calendar) -> Date? { + let calendar = CostUsageDayRange.localGregorianCalendar(matching: calendar) + return self.parseDayKey(dayKey, calendar: calendar).map { calendar.startOfDay(for: $0) } } private static func dayKeys(sinceKey: String, untilKey: String) -> [String] { @@ -1208,15 +1220,16 @@ enum CostUsageScanner { private static func listCodexSessionFilesByDatePartition( root: URL, scanSinceKey: String, - scanUntilKey: String) -> [URL] + scanUntilKey: String, + calendar: Calendar = .current) -> [URL] { guard FileManager.default.fileExists(atPath: root.path) else { return [] } + let calendar = CostUsageDayRange.localGregorianCalendar(matching: calendar) var out: [URL] = [] - var date = Self.parseDayKey(scanSinceKey) ?? Date() - let untilDate = Self.parseDayKey(scanUntilKey) ?? date + var date = Self.parseDayKey(scanSinceKey, calendar: calendar) ?? Date() + let untilDate = Self.parseDayKey(scanUntilKey, calendar: calendar) ?? date while date <= untilDate { - let calendar = CostUsageDayRange.localGregorianCalendar() let comps = calendar.dateComponents([.year, .month, .day], from: date) let y = String(format: "%04d", comps.year ?? 1970) let m = String(format: "%02d", comps.month ?? 1) @@ -2863,8 +2876,7 @@ enum CostUsageScanner { let cachedSinceKey = cache.scanSinceKey let cachedUntilKey = cache.scanUntilKey let shouldRunColdCacheLookback = cache.files.isEmpty || plan.rootsChanged - let coldCacheLookbackStart = Self.parseDayKey(range.scanSinceKey) - .map { CostUsageDayRange.localGregorianCalendar().startOfDay(for: $0) } + let coldCacheLookbackStart = Self.localStartOfDay(range.scanSinceKey, calendar: options.calendar) var seenPaths: Set = [] var files: [URL] = [] for root in plan.roots { @@ -2872,7 +2884,8 @@ enum CostUsageScanner { root: root, scanSinceKey: range.scanSinceKey, scanUntilKey: range.scanUntilKey, - includeRecursive: options.forceRescan) + includeRecursive: options.forceRescan, + calendar: options.calendar) for fileURL in rootFiles.sorted(by: { $0.path < $1.path }) where !seenPaths.contains(fileURL.path) { seenPaths.insert(fileURL.path) files.append(fileURL) @@ -2883,7 +2896,8 @@ enum CostUsageScanner { root: root, scanSinceKey: range.scanSinceKey, scanUntilKey: range.scanUntilKey, - modifiedSince: coldCacheLookbackStart) + modifiedSince: coldCacheLookbackStart, + calendar: options.calendar) for fileURL in recentlyModifiedFiles.sorted(by: { $0.path < $1.path }) where !seenPaths.contains(fileURL.path) { diff --git a/Tests/CodexBarTests/CostUsageCalendarTests.swift b/Tests/CodexBarTests/CostUsageCalendarTests.swift index d6aa8f0dde..15a4a4d727 100644 --- a/Tests/CodexBarTests/CostUsageCalendarTests.swift +++ b/Tests/CodexBarTests/CostUsageCalendarTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import CodexBarCore +@Suite(.serialized) struct CostUsageCalendarTests { @Test func `day keys remain Gregorian under a Buddhist calendar`() throws { @@ -36,4 +37,110 @@ struct CostUsageCalendarTests { from: parsed, calendar: buddhist) == "2026-07-23") } + + @Test + func `warm cache discovers a new Gregorian partition under a Buddhist calendar`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let bangkok = try #require(TimeZone(identifier: "Asia/Bangkok")) + var gregorian = Calendar(identifier: .gregorian) + gregorian.timeZone = bangkok + let firstDay = try #require(gregorian.date(from: DateComponents( + timeZone: bangkok, + year: 2026, + month: 7, + day: 22, + hour: 12))) + let secondDay = try #require(gregorian.date(byAdding: .day, value: 1, to: firstDay)) + var buddhist = Calendar(identifier: .buddhist) + buddhist.timeZone = bangkok + #expect(buddhist.component(.year, from: secondDay) == 2569) + + let firstURL = try Self.writeCodexSession( + env: env, + day: firstDay, + partitionCalendar: gregorian, + filename: "first.jsonl", + tokens: 10) + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite"), + calendar: buddhist) + options.refreshMinIntervalSeconds = 0 + + let firstReport = CostUsageScanner.loadDailyReport( + provider: .codex, + since: firstDay, + until: firstDay, + now: firstDay, + options: options) + #expect(firstReport.data.map(\.date) == ["2026-07-22"]) + #expect(firstReport.data.first?.totalTokens == 10) + + let secondURL = try Self.writeCodexSession( + env: env, + day: secondDay, + partitionCalendar: gregorian, + filename: "second.jsonl", + tokens: 20) + let secondReport = CostUsageScanner.loadDailyReport( + provider: .codex, + since: secondDay, + until: secondDay, + now: secondDay, + options: options) + let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + + #expect(secondReport.data.map(\.date) == ["2026-07-23"]) + #expect(secondReport.data.first?.totalTokens == 20) + #expect(cache.scanSinceKey == "2026-07-21") + #expect(cache.scanUntilKey == "2026-07-24") + #expect(Set(cache.files.keys.map { URL(fileURLWithPath: $0).standardizedFileURL.path }) == Set([ + firstURL.standardizedFileURL.path, + secondURL.standardizedFileURL.path, + ])) + } + + private static func writeCodexSession( + env: CostUsageTestEnvironment, + day: Date, + partitionCalendar: Calendar, + filename: String, + tokens: Int) throws -> URL + { + let components = partitionCalendar.dateComponents([.year, .month, .day], from: day) + let directory = env.codexSessionsRoot + .appendingPathComponent(String(format: "%04d", components.year ?? 1970), isDirectory: true) + .appendingPathComponent(String(format: "%02d", components.month ?? 1), isDirectory: true) + .appendingPathComponent(String(format: "%02d", components.day ?? 1), isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + let model = "openai/gpt-5.4" + let url = directory.appendingPathComponent(filename, isDirectory: false) + try env.jsonl([ + [ + "type": "turn_context", + "timestamp": env.isoString(for: day), + "payload": ["model": model], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "last_token_usage": [ + "input_tokens": tokens, + "cached_input_tokens": 0, + "output_tokens": 0, + ], + "model": model, + ], + ], + ], + ]).write(to: url, atomically: true, encoding: .utf8) + return url + } } diff --git a/Tests/CodexBarTests/CostUsageWindowSummaryTests.swift b/Tests/CodexBarTests/CostUsageWindowSummaryTests.swift index 94b640ef10..e7b150ec9b 100644 --- a/Tests/CodexBarTests/CostUsageWindowSummaryTests.swift +++ b/Tests/CodexBarTests/CostUsageWindowSummaryTests.swift @@ -42,6 +42,48 @@ struct CostUsageWindowSummaryTests { #expect(summary.totalRequests == nil) } + @Test + func `comparison summaries keep Gregorian entries under a Buddhist calendar`() throws { + let bangkok = try #require(TimeZone(identifier: "Asia/Bangkok")) + var gregorian = Calendar(identifier: .gregorian) + gregorian.timeZone = bangkok + let now = try #require(gregorian.date(from: DateComponents( + timeZone: bangkok, + year: 2026, + month: 7, + day: 23, + hour: 12))) + var buddhist = Calendar(identifier: .buddhist) + buddhist.timeZone = bangkok + #expect(buddhist.component(.year, from: now) == 2569) + + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 900, + last30DaysCostUSD: 9, + historyDays: 90, + daily: [ + Self.entry(day: "2026-06-23", cost: 1, tokens: 100, requests: 1), + Self.entry(day: "2026-06-24", cost: 4, tokens: 400, requests: 4), + Self.entry(day: "2026-07-16", cost: 1, tokens: 100, requests: 1), + Self.entry(day: "2026-07-17", cost: 2, tokens: 200, requests: 2), + Self.entry(day: "2026-07-23", cost: 3, tokens: 300, requests: 3), + ], + updatedAt: now) + + let summaries = snapshot.comparisonSummaries(periods: [7, 30], calendar: buddhist) + let sevenDays = try #require(summaries.first { $0.days == 7 }) + let thirtyDays = try #require(summaries.first { $0.days == 30 }) + + #expect(sevenDays.entryCount == 2) + #expect(sevenDays.totalCostUSD == 5) + #expect(sevenDays.totalTokens == 500) + #expect(thirtyDays.entryCount == 4) + #expect(thirtyDays.totalCostUSD == 10) + #expect(thirtyDays.totalTokens == 1000) + } + private static func snapshot(historyDays: Int) -> CostUsageTokenSnapshot { CostUsageTokenSnapshot( sessionTokens: 500, From 83794e8793292e6f4074adcd1a43ba6ed763f66b Mon Sep 17 00:00:00 2001 From: Milan Mijatovic Date: Thu, 23 Jul 2026 18:23:44 +0700 Subject: [PATCH 3/3] Fix cost usage time zone invalidation --- Sources/CodexBarCore/CostUsageFetcher.swift | 38 +- .../Generated/CodexParserHash.generated.swift | 2 +- Sources/CodexBarCore/PiSessionCostCache.swift | 9 +- .../CodexBarCore/PiSessionCostScanner.swift | 67 +++- .../Vendored/CostUsage/CostUsageCache.swift | 11 +- .../CostUsage/CostUsageScanner+Claude.swift | 14 +- .../Vendored/CostUsage/CostUsageScanner.swift | 90 +++-- .../CostUsageCalendarTests.swift | 324 +++++++++++++++++- 8 files changed, 480 insertions(+), 75 deletions(-) diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 295fa75e06..13876e409a 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -193,10 +193,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, @@ -212,6 +209,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( @@ -236,6 +235,7 @@ public struct CostUsageFetcher: Sendable { if resolvedPiOptions.cacheRoot == nil { resolvedPiOptions.cacheRoot = options.cacheRoot } + resolvedPiOptions.calendar = options.calendar if forceRefresh || bypassScannerDebounce { resolvedPiOptions.refreshMinIntervalSeconds = 0 } @@ -251,7 +251,7 @@ public struct CostUsageFetcher: Sendable { var daily = try CostUsageScanner.loadDailyReportCancellable( provider: provider, since: since, - until: until, + until: now, now: now, options: scanOptions, checkCancellation: checkCancellation) @@ -267,7 +267,7 @@ public struct CostUsageFetcher: Sendable { daily = try CostUsageScanner.loadDailyReportCancellable( provider: provider, since: since, - until: until, + until: now, now: now, options: fallback, checkCancellation: checkCancellation) @@ -282,7 +282,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, @@ -297,7 +298,7 @@ public struct CostUsageFetcher: Sendable { let piReport = try PiSessionCostScanner.loadDailyReportCancellable( provider: provider, since: since, - until: until, + until: now, now: now, options: piOptions, checkCancellation: checkCancellation) @@ -349,6 +350,7 @@ public struct CostUsageFetcher: Sendable { from: scanResult.daily, now: now, historyDays: clampedHistoryDays, + calendar: scanOptions.calendar, projects: scanResult.projects, sessions: scanResult.sessions) } @@ -470,10 +472,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), @@ -487,7 +495,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) { @@ -521,7 +530,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 @@ -546,6 +556,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()), @@ -627,6 +638,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, @@ -635,7 +647,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 { diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index b074e92154..13c6e33306 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "7abccfd3729251f2" + static let value = "44f1dfa1688efc9f" } diff --git a/Sources/CodexBarCore/PiSessionCostCache.swift b/Sources/CodexBarCore/PiSessionCostCache.swift index 1e9f2fcc45..7c4582a815 100644 --- a/Sources/CodexBarCore/PiSessionCostCache.swift +++ b/Sources/CodexBarCore/PiSessionCostCache.swift @@ -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 { @@ -52,6 +58,7 @@ 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] = [:] diff --git a/Sources/CodexBarCore/PiSessionCostScanner.swift b/Sources/CodexBarCore/PiSessionCostScanner.swift index de333da15d..4ecdbd3d63 100644 --- a/Sources/CodexBarCore/PiSessionCostScanner.swift +++ b/Sources/CodexBarCore/PiSessionCostScanner.swift @@ -20,6 +20,7 @@ enum PiSessionCostScanner { var piSessionsRoot: URL? var ompSessionsRoot: URL? var cacheRoot: URL? + var calendar: Calendar var refreshMinIntervalSeconds: TimeInterval = 60 var forceRescan: Bool = false @@ -27,12 +28,14 @@ enum PiSessionCostScanner { piSessionsRoot: URL? = nil, ompSessionsRoot: URL? = nil, cacheRoot: URL? = nil, + calendar: Calendar = .current, refreshMinIntervalSeconds: TimeInterval = 60, forceRescan: Bool = false) { self.piSessionsRoot = piSessionsRoot self.ompSessionsRoot = ompSessionsRoot self.cacheRoot = cacheRoot + self.calendar = calendar self.refreshMinIntervalSeconds = refreshMinIntervalSeconds self.forceRescan = forceRescan } @@ -108,8 +111,14 @@ enum PiSessionCostScanner { return CostUsageDailyReport(data: [], summary: nil) } - let range = CostUsageScanner.CostUsageDayRange(since: since, until: until) + let range = CostUsageScanner.CostUsageDayRange( + since: since, + until: until, + calendar: options.calendar) var cache = PiSessionCostCacheIO.load(cacheRoot: options.cacheRoot) + if cache.timeZoneIdentifier != range.calendar.timeZone.identifier { + cache = PiSessionCostCache() + } let nowMs = Int64(now.timeIntervalSince1970 * 1000) let refreshMs = Int64(max(0, options.refreshMinIntervalSeconds) * 1000) let pricingContext = self.pricingContext(now: now, cacheRoot: options.cacheRoot) @@ -125,10 +134,14 @@ enum PiSessionCostScanner { if shouldRefresh { try checkCancellation?() let roots = self.defaultSessionRoots(options: options) - let startCutoff = self.dateFromDayKey(range.scanSinceKey) ?? since + let startCutoff = self.dateFromDayKey(range.scanSinceKey, calendar: range.calendar) ?? since var files: [SessionFileCandidate] = [] for (rootIndex, root) in roots.enumerated() { - for url in self.listPiSessionFiles(root: root, startCutoffLocal: startCutoff) { + for url in self.listPiSessionFiles( + root: root, + startCutoffLocal: startCutoff, + calendar: range.calendar) + { files.append(SessionFileCandidate(url: url, rootIndex: rootIndex)) } } @@ -168,7 +181,10 @@ enum PiSessionCostScanner { cache.pricingKey = pricingContext.pricingKey cache.lastScanUnixMs = nowMs try checkCancellation?() - PiSessionCostCacheIO.save(cache: cache, cacheRoot: options.cacheRoot) + PiSessionCostCacheIO.save( + cache: cache, + cacheRoot: options.cacheRoot, + calendar: range.calendar) } return self.buildReport( @@ -188,14 +204,16 @@ enum PiSessionCostScanner { since: Date, until: Date, now: Date = Date(), - cacheRoot: URL? = nil) -> CostUsageDailyReport? + cacheRoot: URL? = nil, + calendar: Calendar = .current) -> CostUsageDailyReport? { self.loadCachedDailyReportResult( provider: provider, since: since, until: until, now: now, - cacheRoot: cacheRoot)?.report + cacheRoot: cacheRoot, + calendar: calendar)?.report } static func loadCachedDailyReportResult( @@ -203,12 +221,14 @@ enum PiSessionCostScanner { since: Date, until: Date, now: Date = Date(), - cacheRoot: URL? = nil) -> CachedDailyReportResult? + cacheRoot: URL? = nil, + calendar: Calendar = .current) -> CachedDailyReportResult? { guard provider == .codex || provider == .claude else { return nil } - let range = CostUsageScanner.CostUsageDayRange(since: since, until: until) + let range = CostUsageScanner.CostUsageDayRange(since: since, until: until, calendar: calendar) let cache = PiSessionCostCacheIO.load(cacheRoot: cacheRoot) + guard cache.timeZoneIdentifier == range.calendar.timeZone.identifier else { return nil } guard !cache.daysByProvider.isEmpty else { return nil } guard !self.requestedWindowExpandsCache(range: range, cache: cache) else { return nil } @@ -271,7 +291,11 @@ enum PiSessionCostScanner { } } - private static func listPiSessionFiles(root: URL, startCutoffLocal: Date) -> [URL] { + private static func listPiSessionFiles( + root: URL, + startCutoffLocal: Date, + calendar: Calendar) -> [URL] + { guard FileManager.default.fileExists(atPath: root.path) else { return [] } let keys: Set = [.isRegularFileKey, .contentModificationDateKey] @@ -292,7 +316,11 @@ enum PiSessionCostScanner { let startedAt = self.parseSessionStartFromFilename(item.lastPathComponent) let modifiedAt = values?.contentModificationDate if self - .shouldIncludeFile(startedAt: startedAt, modifiedAt: modifiedAt, startCutoffLocal: startCutoffLocal) + .shouldIncludeFile( + startedAt: startedAt, + modifiedAt: modifiedAt, + startCutoffLocal: startCutoffLocal, + calendar: calendar) { output.append(item) } @@ -304,12 +332,13 @@ enum PiSessionCostScanner { private static func shouldIncludeFile( startedAt: Date?, modifiedAt: Date?, - startCutoffLocal: Date) -> Bool + startCutoffLocal: Date, + calendar: Calendar) -> Bool { - if let modifiedAt, self.localMidnight(modifiedAt) >= startCutoffLocal { + if let modifiedAt, self.localMidnight(modifiedAt, calendar: calendar) >= startCutoffLocal { return true } - if let startedAt, self.localMidnight(startedAt) >= startCutoffLocal { + if let startedAt, self.localMidnight(startedAt, calendar: calendar) >= startCutoffLocal { return true } return false @@ -508,7 +537,9 @@ enum PiSessionCostScanner { fallback: currentModelContext) guard let identity else { return } guard let date = self.timestampDate(entry: object, message: message) else { return } - let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: date) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey( + from: date, + calendar: range.calendar) let usage = self.extractUsage( provider: identity.provider, modelName: identity.modelName, @@ -1039,20 +1070,20 @@ extension PiSessionCostScanner { ?? self.isoFormatterBox.plain.date(from: text) } - private static func localMidnight(_ date: Date) -> Date { - let calendar = CostUsageScanner.CostUsageDayRange.localGregorianCalendar() + private static func localMidnight(_ date: Date, calendar: Calendar) -> Date { + let calendar = CostUsageScanner.CostUsageDayRange.localGregorianCalendar(matching: calendar) let components = calendar.dateComponents([.year, .month, .day], from: date) return calendar.date(from: components) ?? date } - private static func dateFromDayKey(_ key: String) -> Date? { + private static func dateFromDayKey(_ key: String, calendar: Calendar) -> Date? { let parts = key.split(separator: "-") guard parts.count == 3, let year = Int(parts[0]), let month = Int(parts[1]), let day = Int(parts[2]) else { return nil } - let calendar = CostUsageScanner.CostUsageDayRange.localGregorianCalendar() + let calendar = CostUsageScanner.CostUsageDayRange.localGregorianCalendar(matching: calendar) var components = DateComponents() components.calendar = calendar components.timeZone = calendar.timeZone diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index de20fe83da..2ba4cd33c3 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -35,7 +35,8 @@ enum CostUsageCacheIO { static func load( provider: UsageProvider, cacheRoot: URL? = nil, - producerKey: String? = nil) -> CostUsageCache + producerKey: String? = nil, + calendar: Calendar? = nil) -> CostUsageCache { let url = self.cacheFileURL(provider: provider, cacheRoot: cacheRoot) let expectedProducerKey = producerKey ?? self.currentProducerKey(provider: provider) @@ -47,6 +48,9 @@ enum CostUsageCacheIO { expectedProducerKey: expectedProducerKey, compatibleProducerKeys: compatibleProducerKeys) { + if let calendar, decoded.timeZoneIdentifier != calendar.timeZone.identifier { + return CostUsageCache() + } return decoded } return CostUsageCache() @@ -73,7 +77,8 @@ enum CostUsageCacheIO { provider: UsageProvider, cache: CostUsageCache, cacheRoot: URL? = nil, - producerKey: String? = nil) + producerKey: String? = nil, + calendar: Calendar = .current) { let url = self.cacheFileURL(provider: provider, cacheRoot: cacheRoot) let dir = url.deletingLastPathComponent() @@ -81,6 +86,7 @@ enum CostUsageCacheIO { var cache = cache cache.producerKey = producerKey ?? self.currentProducerKey(provider: provider) + cache.timeZoneIdentifier = calendar.timeZone.identifier let tmp = dir.appendingPathComponent(".tmp-\(UUID().uuidString).json", isDirectory: false) let data = (try? JSONEncoder().encode(cache)) ?? Data() @@ -111,6 +117,7 @@ struct CostUsageCache: Codable { var lastScanUnixMs: Int64 = 0 var scanSinceKey: String? var scanUntilKey: String? + var timeZoneIdentifier: String? var codexPricingKey: String? var codexPriorityMetadataKey: String? var codexProjectMetadataVersion: Int? diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 2f61dabada..e862bb349e 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -162,7 +162,8 @@ extension CostUsageScanner { guard let tsText = obj["timestamp"] as? String, let timestamp = Self.dateFromTimestamp(tsText) else { return } - guard let dayKey = Self.dayKeyFromTimestamp(tsText) ?? Self.dayKeyFromParsedISO(tsText) + guard let dayKey = Self.dayKeyFromTimestamp(tsText, calendar: range.calendar) + ?? Self.dayKeyFromParsedISO(tsText, calendar: range.calendar) else { return } guard let message = obj["message"] as? [String: Any] else { return } @@ -656,7 +657,10 @@ extension CostUsageScanner { options: Options, checkCancellation: CancellationCheck?) throws -> CostUsageDailyReport { - var cache = CostUsageCacheIO.load(provider: provider, cacheRoot: options.cacheRoot) + var cache = CostUsageCacheIO.load( + provider: provider, + cacheRoot: options.cacheRoot, + calendar: range.calendar) let nowMs = Int64(now.timeIntervalSince1970 * 1000) let refreshMs = Int64(max(0, options.refreshMinIntervalSeconds) * 1000) @@ -708,7 +712,11 @@ extension CostUsageScanner { cache.scanUntilKey = range.scanUntilKey cache.lastScanUnixMs = nowMs try checkCancellation?() - CostUsageCacheIO.save(provider: provider, cache: cache, cacheRoot: options.cacheRoot) + CostUsageCacheIO.save( + provider: provider, + cache: cache, + cacheRoot: options.cacheRoot, + calendar: range.calendar) } let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: options.cacheRoot) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 498bad4167..036ff582f3 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -845,9 +845,11 @@ enum CostUsageScanner { let untilKey: String let scanSinceKey: String let scanUntilKey: String + let calendar: Calendar init(since: Date, until: Date, calendar: Calendar = .current) { let calendar = Self.localGregorianCalendar(matching: calendar) + self.calendar = calendar self.sinceKey = Self.dayKey(from: since, calendar: calendar) self.untilKey = Self.dayKey(from: until, calendar: calendar) let scanSince = calendar.date(byAdding: .day, value: -1, to: since) ?? since @@ -1002,11 +1004,12 @@ enum CostUsageScanner { } private static func codexPriorityTurnKeys( - _ priorityTurns: [String: CodexPriorityTurnMetadata]) -> [String: String] + _ priorityTurns: [String: CodexPriorityTurnMetadata], + calendar: Calendar) -> [String: String] { var partsByDay: [String: [String]] = [:] for (turnID, turn) in priorityTurns { - guard let dayKey = self.codexPriorityDayKey(turn) else { continue } + guard let dayKey = self.codexPriorityDayKey(turn, calendar: calendar) else { continue } partsByDay[dayKey, default: []].append([ turnID, turn.model ?? "", @@ -1022,22 +1025,30 @@ enum CostUsageScanner { } private static func codexPriorityTurnIDsByDay( - _ priorityTurns: [String: CodexPriorityTurnMetadata]) -> [String: [String]] + _ priorityTurns: [String: CodexPriorityTurnMetadata], + calendar: Calendar) -> [String: [String]] { var out: [String: Set] = [:] for (turnID, turn) in priorityTurns { - guard let dayKey = self.codexPriorityDayKey(turn) else { continue } + guard let dayKey = self.codexPriorityDayKey(turn, calendar: calendar) else { continue } out[dayKey, default: []].insert(turnID) } return out.mapValues { $0.sorted() } } - private static func codexPriorityDayKey(_ turn: CodexPriorityTurnMetadata) -> String? { + private static func codexPriorityDayKey( + _ turn: CodexPriorityTurnMetadata, + calendar: Calendar) -> String? + { guard let timestamp = turn.timestamp else { return nil } let dayKeyFromEpoch = Int64(timestamp).map { - CostUsageDayRange.dayKey(from: Date(timeIntervalSince1970: TimeInterval($0))) + CostUsageDayRange.dayKey( + from: Date(timeIntervalSince1970: TimeInterval($0)), + calendar: calendar) } - return dayKeyFromEpoch ?? self.dayKeyFromTimestamp(timestamp) ?? self.dayKeyFromParsedISO(timestamp) + return dayKeyFromEpoch + ?? self.dayKeyFromTimestamp(timestamp, calendar: calendar) + ?? self.dayKeyFromParsedISO(timestamp, calendar: calendar) } private static func codexPriorityTurnKeysChanged( @@ -1045,7 +1056,10 @@ enum CostUsageScanner { new: [String: String], range: CostUsageDayRange) -> Bool { - for dayKey in self.dayKeys(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) + for dayKey in self.dayKeys( + sinceKey: range.scanSinceKey, + untilKey: range.scanUntilKey, + calendar: range.calendar) where old?[dayKey] != new[dayKey] { return true @@ -1061,7 +1075,11 @@ enum CostUsageScanner { range: CostUsageDayRange) -> Set { var out = Set() - for dayKey in self.dayKeys(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) { + for dayKey in self.dayKeys( + sinceKey: range.scanSinceKey, + untilKey: range.scanUntilKey, + calendar: range.calendar) + { let oldIDs = Set(old?[dayKey] ?? []) let newIDs = Set(new[dayKey] ?? []) if oldIDs != newIDs || oldKeys?[dayKey] != newKeys[dayKey] { @@ -1080,7 +1098,11 @@ enum CostUsageScanner { retainedUntilKey: String) -> [String: String]? { var out = existing ?? [:] - for dayKey in self.dayKeys(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) { + for dayKey in self.dayKeys( + sinceKey: range.scanSinceKey, + untilKey: range.scanUntilKey, + calendar: range.calendar) + { out[dayKey] = new[dayKey] } out = out.filter { key, _ in @@ -1097,7 +1119,11 @@ enum CostUsageScanner { retainedUntilKey: String) -> [String: [String]]? { var out = existing ?? [:] - for dayKey in self.dayKeys(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) { + for dayKey in self.dayKeys( + sinceKey: range.scanSinceKey, + untilKey: range.scanUntilKey, + calendar: range.calendar) + { out[dayKey] = new[dayKey] ?? [] } out = out.filter { key, _ in @@ -1168,16 +1194,20 @@ enum CostUsageScanner { return self.parseDayKey(dayKey, calendar: calendar).map { calendar.startOfDay(for: $0) } } - private static func dayKeys(sinceKey: String, untilKey: String) -> [String] { - guard let since = self.parseDayKey(sinceKey), - self.parseDayKey(untilKey) != nil + private static func dayKeys( + sinceKey: String, + untilKey: String, + calendar: Calendar = .current) -> [String] + { + let calendar = CostUsageDayRange.localGregorianCalendar(matching: calendar) + guard let since = self.parseDayKey(sinceKey, calendar: calendar), + self.parseDayKey(untilKey, calendar: calendar) != nil else { return sinceKey <= untilKey ? [sinceKey] : [] } var out: [String] = [] var cursor = since - let calendar = CostUsageDayRange.localGregorianCalendar() - while CostUsageDayRange.dayKey(from: cursor) <= untilKey { - out.append(CostUsageDayRange.dayKey(from: cursor)) + while CostUsageDayRange.dayKey(from: cursor, calendar: calendar) <= untilKey { + out.append(CostUsageDayRange.dayKey(from: cursor, calendar: calendar)) guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break } if next <= cursor { break @@ -2158,7 +2188,8 @@ enum CostUsageScanner { // swiftlint:disable:next function_body_length func handleTokenCount(_ record: CodexTokenCountRecord) throws { - guard let dayKey = Self.dayKeyFromTimestamp(record.timestamp) ?? Self.dayKeyFromParsedISO(record.timestamp) + guard let dayKey = Self.dayKeyFromTimestamp(record.timestamp, calendar: range.calendar) + ?? Self.dayKeyFromParsedISO(record.timestamp, calendar: range.calendar) else { return } guard !suppressUnownedCopiedPrefix else { return } @@ -2805,8 +2836,8 @@ enum CostUsageScanner { databaseURL: options.codexTraceDatabaseURL, sinceDayKey: range.scanSinceKey, untilDayKey: range.scanUntilKey) : [:] - let priorityTurnKeys = Self.codexPriorityTurnKeys(priorityTurns) - let priorityTurnIDsByDay = Self.codexPriorityTurnIDsByDay(priorityTurns) + let priorityTurnKeys = Self.codexPriorityTurnKeys(priorityTurns, calendar: range.calendar) + let priorityTurnIDsByDay = Self.codexPriorityTurnIDsByDay(priorityTurns, calendar: range.calendar) let priorityTurnsChanged = shouldInspectPriorityTurns && hasPriorityMetadata && Self.codexPriorityTurnKeysChanged( @@ -2857,13 +2888,28 @@ enum CostUsageScanner { shouldRefresh: shouldRefresh) } + private static func loadCodexCache(options: Options, range: CostUsageDayRange) -> CostUsageCache { + CostUsageCacheIO.load( + provider: .codex, + cacheRoot: options.cacheRoot, + calendar: range.calendar) + } + + private static func saveCodexCache(_ cache: CostUsageCache, options: Options, range: CostUsageDayRange) { + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: options.cacheRoot, + calendar: range.calendar) + } + private static func loadCodexDaily( range: CostUsageDayRange, now: Date, options: Options, checkCancellation: CancellationCheck?) throws -> CostUsageDailyReport { - var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: options.cacheRoot) + var cache = Self.loadCodexCache(options: options, range: range) let nowMs = Int64(now.timeIntervalSince1970 * 1000) let plan = Self.makeCodexRefreshPlan(cache: cache, range: range, now: now, nowMs: nowMs, options: options) @@ -3013,7 +3059,7 @@ enum CostUsageScanner { } cache.lastScanUnixMs = nowMs try checkCancellation?() - CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: options.cacheRoot) + Self.saveCodexCache(cache, options: options, range: range) } return Self.buildCodexReportFromCache( diff --git a/Tests/CodexBarTests/CostUsageCalendarTests.swift b/Tests/CodexBarTests/CostUsageCalendarTests.swift index 15a4a4d727..47cf4303e8 100644 --- a/Tests/CodexBarTests/CostUsageCalendarTests.swift +++ b/Tests/CodexBarTests/CostUsageCalendarTests.swift @@ -103,6 +103,303 @@ struct CostUsageCalendarTests { ])) } + @Test + func `codex cache re-buckets unchanged files when the time zone changes`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let utc = try Self.calendar(timeZoneIdentifier: "UTC") + let bangkok = try Self.calendar(timeZoneIdentifier: "Asia/Bangkok") + let boundary = try Self.date("2026-07-22T18:00:00Z") + let windowStart = try Self.date("2026-07-20T12:00:00Z") + let windowEnd = try Self.date("2026-07-24T12:00:00Z") + _ = try Self.writeCodexSession( + env: env, + day: boundary, + partitionCalendar: utc, + filename: "time-zone-change.jsonl", + tokens: 10) + + let utcReport = CostUsageScanner.loadDailyReport( + provider: .codex, + since: windowStart, + until: windowEnd, + now: windowEnd, + options: Self.codexOptions(env: env, calendar: utc)) + let utcCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + #expect(utcReport.data.map(\.date) == ["2026-07-22"]) + #expect(utcCache.timeZoneIdentifier == utc.timeZone.identifier) + #expect(utcCache.files.values.compactMap(\.sessionId) == ["calendar-time-zone-change.jsonl"]) + + let bangkokReport = CostUsageScanner.loadDailyReport( + provider: .codex, + since: windowStart, + until: windowEnd, + now: windowEnd, + options: Self.codexOptions(env: env, calendar: bangkok)) + let bangkokCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + #expect(bangkokReport.data.map(\.date) == ["2026-07-23"]) + #expect(bangkokReport.data.first?.totalTokens == 10) + #expect(bangkokCache.timeZoneIdentifier == "Asia/Bangkok") + } + + @Test + func `codex cache does not mix old and new zones after an append`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let utc = try Self.calendar(timeZoneIdentifier: "UTC") + let bangkok = try Self.calendar(timeZoneIdentifier: "Asia/Bangkok") + let boundary = try Self.date("2026-07-22T18:00:00Z") + let windowStart = try Self.date("2026-07-20T12:00:00Z") + let windowEnd = try Self.date("2026-07-24T12:00:00Z") + let fileURL = try Self.writeCodexSession( + env: env, + day: boundary, + partitionCalendar: utc, + filename: "time-zone-append.jsonl", + tokens: 10) + + let utcReport = CostUsageScanner.loadDailyReport( + provider: .codex, + since: windowStart, + until: windowEnd, + now: windowEnd, + options: Self.codexOptions(env: env, calendar: utc)) + #expect(utcReport.data.map(\.date) == ["2026-07-22"]) + + let handle = try FileHandle(forWritingTo: fileURL) + defer { try? handle.close() } + try handle.seekToEnd() + let appended = try env.jsonl([ + Self.codexTokenCount( + timestamp: env.isoString(for: boundary.addingTimeInterval(2)), + tokens: 30), + ]) + try handle.write(contentsOf: Data(appended.utf8)) + try handle.close() + + let bangkokReport = CostUsageScanner.loadDailyReport( + provider: .codex, + since: windowStart, + until: windowEnd, + now: windowEnd, + options: Self.codexOptions(env: env, calendar: bangkok)) + #expect(bangkokReport.data.map(\.date) == ["2026-07-23"]) + #expect(bangkokReport.data.first?.totalTokens == 30) + } + + @Test + func `fetcher keeps daily project session and cached ranges in the injected zone`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let utc = try Self.calendar(timeZoneIdentifier: "UTC") + let bangkok = try Self.calendar(timeZoneIdentifier: "Asia/Bangkok") + let boundary = try Self.date("2026-07-22T18:00:00Z") + _ = try Self.writeCodexSession( + env: env, + day: boundary, + partitionCalendar: utc, + filename: "fetcher-time-zone.jsonl", + tokens: 10) + + try await Self.expectFetcherSnapshot( + env: env, + now: boundary, + calendar: utc, + expectedDay: "2026-07-22") + try await Self.expectFetcherSnapshot( + env: env, + now: boundary, + calendar: bangkok, + expectedDay: "2026-07-23") + } + + @Test + func `claude and pi caches re-bucket unchanged files when the time zone changes`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let utc = try Self.calendar(timeZoneIdentifier: "UTC") + let bangkok = try Self.calendar(timeZoneIdentifier: "Asia/Bangkok") + let boundary = try Self.date("2026-07-22T18:00:00Z") + let windowStart = try Self.date("2026-07-20T12:00:00Z") + let windowEnd = try Self.date("2026-07-24T12:00:00Z") + _ = try env.writeClaudeProjectFile( + relativePath: "calendar/session.jsonl", + contents: env.jsonl([ + [ + "type": "assistant", + "timestamp": env.isoString(for: boundary), + "sessionId": "claude-calendar-session", + "requestId": "claude-calendar-request", + "message": [ + "id": "claude-calendar-message", + "model": "claude-sonnet-4-20250514", + "usage": [ + "input_tokens": 10, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 2, + ], + ], + ], + ])) + _ = try env.writePiSessionFile( + relativePath: "calendar/2026-07-22T18-00-00-000Z_session.jsonl", + contents: env.jsonl([ + [ + "type": "message", + "timestamp": env.isoString(for: boundary), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "openai/gpt-5.4", + "timestamp": Int(boundary.timeIntervalSince1970 * 1000), + "usage": [ + "input": 10, + "output": 2, + "cacheRead": 0, + "cacheWrite": 0, + "totalTokens": 12, + ], + ], + ], + ])) + + let utcClaude = CostUsageScanner.loadDailyReport( + provider: .claude, + since: windowStart, + until: windowEnd, + now: windowEnd, + options: Self.claudeOptions(env: env, calendar: utc)) + let bangkokClaude = CostUsageScanner.loadDailyReport( + provider: .claude, + since: windowStart, + until: windowEnd, + now: windowEnd, + options: Self.claudeOptions(env: env, calendar: bangkok)) + #expect(utcClaude.data.map(\.date) == ["2026-07-22"]) + #expect(bangkokClaude.data.map(\.date) == ["2026-07-23"]) + #expect(CostUsageCacheIO.load( + provider: .claude, + cacheRoot: env.cacheRoot).timeZoneIdentifier == "Asia/Bangkok") + + let utcPi = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: windowStart, + until: windowEnd, + now: windowEnd, + options: Self.piOptions(env: env, calendar: utc)) + let bangkokPi = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: windowStart, + until: windowEnd, + now: windowEnd, + options: Self.piOptions(env: env, calendar: bangkok)) + #expect(utcPi.data.map(\.date) == ["2026-07-22"]) + #expect(bangkokPi.data.map(\.date) == ["2026-07-23"]) + #expect(PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot).timeZoneIdentifier == "Asia/Bangkok") + } + + private static func expectFetcherSnapshot( + env: CostUsageTestEnvironment, + now: Date, + calendar: Calendar, + expectedDay: String) async throws + { + let options = Self.codexOptions(env: env, calendar: calendar) + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: now, + historyDays: 1, + allowPricingRefresh: false, + includePiSessions: false, + scannerOptions: options) + #expect(snapshot.daily.map(\.date) == [expectedDay]) + #expect(snapshot.projects.flatMap(\.daily).map(\.date) == [expectedDay]) + #expect(snapshot.sessions.map(\.sessionID) == ["calendar-fetcher-time-zone.jsonl"]) + #expect(snapshot.sessionTokens == 10) + + let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: now, + historyDays: 1, + scannerOptions: options) + #expect(cached?.daily.map(\.date) == [expectedDay]) + #expect(cached?.projects.flatMap(\.daily).map(\.date) == [expectedDay]) + #expect(cached?.sessions.map(\.sessionID) == ["calendar-fetcher-time-zone.jsonl"]) + #expect(cached?.sessionTokens == 10) + } + + private static func codexOptions( + env: CostUsageTestEnvironment, + calendar: Calendar) -> CostUsageScanner.Options + { + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite"), + calendar: calendar) + options.refreshMinIntervalSeconds = 0 + return options + } + + private static func claudeOptions( + env: CostUsageTestEnvironment, + calendar: Calendar) -> CostUsageScanner.Options + { + var options = CostUsageScanner.Options( + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot, + calendar: calendar) + options.refreshMinIntervalSeconds = 0 + return options + } + + private static func piOptions( + env: CostUsageTestEnvironment, + calendar: Calendar) -> PiSessionCostScanner.Options + { + PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + calendar: calendar, + refreshMinIntervalSeconds: 0) + } + + private static func calendar(timeZoneIdentifier: String) throws -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: timeZoneIdentifier)) + return calendar + } + + private static func date(_ text: String) throws -> Date { + try #require(ISO8601DateFormatter().date(from: text)) + } + + private static func codexTokenCount( + timestamp: String, + tokens: Int, + model: String = "openai/gpt-5.4") -> [String: Any] + { + [ + "type": "event_msg", + "timestamp": timestamp, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": tokens, + "cached_input_tokens": 0, + "output_tokens": 0, + ], + "model": model, + ], + ], + ] + } + private static func writeCodexSession( env: CostUsageTestEnvironment, day: Date, @@ -121,25 +418,22 @@ struct CostUsageCalendarTests { let url = directory.appendingPathComponent(filename, isDirectory: false) try env.jsonl([ [ - "type": "turn_context", + "type": "session_meta", "timestamp": env.isoString(for: day), - "payload": ["model": model], - ], - [ - "type": "event_msg", - "timestamp": env.isoString(for: day.addingTimeInterval(1)), "payload": [ - "type": "token_count", - "info": [ - "last_token_usage": [ - "input_tokens": tokens, - "cached_input_tokens": 0, - "output_tokens": 0, - ], - "model": model, - ], + "id": "calendar-\(filename)", + "cwd": env.root.appendingPathComponent("calendar-project", isDirectory: true).path, ], ], + [ + "type": "turn_context", + "timestamp": env.isoString(for: day), + "payload": ["model": model], + ], + Self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + tokens: tokens, + model: model), ]).write(to: url, atomically: true, encoding: .utf8) return url }