import Foundationimport Testing@testable import CodexBarCore // swiftlint:disable file_length// swiftlint:disable type_body_lengthstruct CostUsageScannerBreakdownTests { private typealias Usage = (input: Int, cached: Int, output: Int) private func codexTurnContext(timestamp: String, model: String) -> [String: Any] { [ "type": "turn_context", "timestamp": timestamp, "payload": [ "model": model, ], ] } private func codexTokenCount( timestamp: String, model: String, total: Usage? = nil, last: Usage? = nil) -> [String: Any] { var info: [String: Any] = [ "model": model, ] if let total { info["total_token_usage"] = [ "input_tokens": total.input, "cached_input_tokens": total.cached, "output_tokens": total.output, ] } if let last { info["last_token_usage"] = [ "input_tokens": last.input, "cached_input_tokens": last.cached, "output_tokens": last.output, ] } return [ "type": "event_msg", "timestamp": timestamp, "payload": [ "type": "token_count", "info": info, ], ] } private func codexTokenCountWithoutModel(timestamp: String, last: Usage) -> [String: Any] { [ "type": "event_msg", "timestamp": timestamp, "payload": [ "type": "token_count", "info": [ "last_token_usage": [ "input_tokens": last.input, "cached_input_tokens": last.cached, "output_tokens": last.output, ], ], ], ] } private func oversizedCodexTurnContextLine(timestamp: String, model: String) -> String { let largeInstructions = String(repeating: "x", count: 300 * 1024) return #"{"type":"turn_context","timestamp":""# + timestamp + #"","payload":{"model":""# + model + #"","instructions":""# + largeInstructions + #""}}"# } private func oversizedCodexTurnContextInfoModelLine(timestamp: String, model: String) -> String { let largeInstructions = String(repeating: "x", count: 300 * 1024) return #"{"type":"turn_context","timestamp":""# + timestamp + #"","payload":{"empty":"","info":{"model":""# + model + #""},"instructions":""# + largeInstructions + #""}}"# } private func oversizedCodexTurnContextPromptOnlyLine(timestamp: String, promptModel: String) -> String { let prompt = #"example: {\"type\":\"turn_context\",\"payload\":{\"model\":\"\#(promptModel)\"}}"# + String(repeating: "x", count: 300 * 1024) return #"{"type":"turn_context","timestamp":""# + timestamp + #"","payload":{"instructions":""# + prompt + #""}}"# } @Test func `codex daily report parses token counts and caches`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2025, month: 12, day: 20) let iso0 = env.isoString(for: day) let iso1 = env.isoString(for: day.addingTimeInterval(1)) let iso2 = env.isoString(for: day.addingTimeInterval(2)) let model = "openai/gpt-5.2-codex" let turnContext: [String: Any] = [ "type": "turn_context", "timestamp": iso0, "payload": [ "model": model, ], ] let firstTokenCount: [String: Any] = [ "type": "event_msg", "timestamp": iso1, "payload": [ "type": "token_count", "info": [ "total_token_usage": [ "input_tokens": 100, "cached_input_tokens": 20, "output_tokens": 10, ], "model": model, ], ], ] let fileURL = try env.writeCodexSessionFile( day: day, filename: "session.jsonl", contents: env.jsonl([turnContext, firstTokenCount])) var options = CostUsageScanner.Options( codexSessionsRoot: env.codexSessionsRoot, claudeProjectsRoots: nil, cacheRoot: env.cacheRoot, codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) options.refreshMinIntervalSeconds = 0 let first = CostUsageScanner.loadDailyReport( provider: .codex, since: day, until: day, now: day, options: options) #expect(first.data.count == 1) #expect(first.data[0].modelsUsed == ["gpt-5.2-codex"]) #expect(first.data[0].modelBreakdowns == [ CostUsageDailyReport.ModelBreakdown( modelName: "gpt-5.2-codex", costUSD: first.data[0].costUSD, totalTokens: 110), ]) #expect(first.data[0].totalTokens == 110) #expect((first.data[0].costUSD ?? 0) > 0) let firstCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) #expect(firstCache.codexPricingKey?.hasPrefix("builtin-") == true) let secondTokenCount: [String: Any] = [ "type": "event_msg", "timestamp": iso2, "payload": [ "type": "token_count", "info": [ "total_token_usage": [ "input_tokens": 160, "cached_input_tokens": 40, "output_tokens": 16, ], "model": model, ], ], ] try env.jsonl([turnContext, firstTokenCount, secondTokenCount]) .write(to: fileURL, atomically: true, encoding: .utf8) let second = CostUsageScanner.loadDailyReport( provider: .codex, since: day, until: day, now: day, options: options) #expect(second.data.count == 1) #expect(second.data[0].modelsUsed == ["gpt-5.2-codex"]) #expect(second.data[0].totalTokens == 176) #expect((second.data[0].costUSD ?? 0) > (first.data[0].costUSD ?? 0)) } @Test func `codex incremental append falls back to rescan when fork metadata appears late`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 3, day: 11) let iso0 = env.isoString(for: day) let iso1 = env.isoString(for: day.addingTimeInterval(1)) let iso2 = env.isoString(for: day.addingTimeInterval(2)) let iso3 = env.isoString(for: day.addingTimeInterval(3)) let model = "gpt-5.4" let sessionMeta: [String: Any] = [ "type": "session_meta", "timestamp": iso0, "payload": ["id": "late-fork-child"], ] let turnContext = self.codexTurnContext(timestamp: iso0, model: model) let firstTokenCount = self.codexTokenCount( timestamp: iso1, model: model, total: (input: 10, cached: 0, output: 0), last: (input: 10, cached: 0, output: 0)) let fileURL = try env.writeCodexSessionFile( day: day, filename: "late-fork-child.jsonl", contents: env.jsonl([sessionMeta, turnContext, firstTokenCount])) var options = CostUsageScanner.Options( codexSessionsRoot: env.codexSessionsRoot, claudeProjectsRoots: nil, cacheRoot: env.cacheRoot, codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) options.refreshMinIntervalSeconds = 0 let first = CostUsageScanner.loadDailyReport( provider: .codex, since: day, until: day, now: day, options: options) #expect(first.data.first?.totalTokens == 10) let lateForkMeta: [String: Any] = [ "type": "session_meta", "timestamp": iso2, "payload": [ "id": "late-fork-child", "forked_from_id": "missing-parent", "timestamp": iso2, ], ] let replayedForkUsage = self.codexTokenCount( timestamp: iso3, model: model, total: (input: 1000, cached: 900, output: 100), last: (input: 1000, cached: 900, output: 100)) let handle = try FileHandle(forWritingTo: fileURL) try handle.seekToEnd() try handle.write(contentsOf: Data(("\n" + env.jsonl([lateForkMeta, replayedForkUsage])).utf8)) try handle.close() let second = CostUsageScanner.loadDailyReport( provider: .codex, since: day, until: day, now: day, options: options) #expect(second.data.first?.totalTokens == 10) } @Test func `codex daily report reprices cached sessions when models dev pricing changes`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) let iso0 = env.isoString(for: day) let iso1 = env.isoString(for: day.addingTimeInterval(1)) let olderDay = try env.makeLocalNoon(year: 2026, month: 5, day: 5) let olderFileURL = try env.writeCodexSessionFile( day: olderDay, filename: "older-session.jsonl", contents: env.jsonl([ self.codexTurnContext(timestamp: env.isoString(for: olderDay), model: "custom-codex-model"), self.codexTokenCount( timestamp: env.isoString(for: olderDay.addingTimeInterval(1)), model: "custom-codex-model", last: (input: 100, cached: 20, output: 10)), ])) try FileManager.default.setAttributes( [.modificationDate: olderDay], ofItemAtPath: olderFileURL.path) let model = "custom-codex-model" _ = try env.writeCodexSessionFile( day: day, filename: "session.jsonl", contents: env.jsonl([ self.codexTurnContext(timestamp: iso0, model: model), self.codexTokenCount(timestamp: iso1, model: model, last: (input: 100, cached: 20, output: 10)), ])) try ModelsDevCache.save( catalog: Self.modelsDevCatalog(model: model, input: 1, output: 2, cacheRead: 0.5), fetchedAt: Date(timeIntervalSince1970: 1), cacheRoot: env.cacheRoot) var options = CostUsageScanner.Options( codexSessionsRoot: env.codexSessionsRoot, claudeProjectsRoots: nil, cacheRoot: env.cacheRoot, codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) options.refreshMinIntervalSeconds = 0 let first = CostUsageScanner.loadDailyReport( provider: .codex, since: olderDay, until: day, now: day, options: options) let oldDailyCost = (80.0 / 1_000_000.0) + (20.0 * 0.5 / 1_000_000.0) + (10.0 * 2.0 / 1_000_000.0) #expect(first.summary?.totalCostUSD == oldDailyCost * 2) try ModelsDevCache.save( catalog: Self.modelsDevCatalog(model: model, input: 1, output: 2, cacheRead: 0.5), fetchedAt: Date(timeIntervalSince1970: 2), cacheRoot: env.cacheRoot) options.refreshMinIntervalSeconds = 60 let samePricing = CostUsageScanner.loadDailyReport( provider: .codex, since: day, until: day, now: day.addingTimeInterval(1), options: options) let samePricingCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) #expect(samePricing.summary?.totalCostUSD == oldDailyCost) #expect(samePricingCache.scanSinceKey == "2026-05-04") try ModelsDevCache.save( catalog: Self.modelsDevCatalog(model: model, input: 10, output: 20, cacheRead: 5), fetchedAt: Date(timeIntervalSince1970: 2), cacheRoot: env.cacheRoot) options.refreshMinIntervalSeconds = 60 let narrowRepriced = CostUsageScanner.loadDailyReport( provider: .codex, since: day, until: day, now: day.addingTimeInterval(2), options: options) let newDailyCost = (80.0 * 10.0 / 1_000_000.0) + (20.0 * 5.0 / 1_000_000.0) + (10.0 * 20.0 / 1_000_000.0) #expect(narrowRepriced.summary?.totalCostUSD == newDailyCost) let wideRepriced = CostUsageScanner.loadDailyReport( provider: .codex, since: olderDay, until: day, now: day.addingTimeInterval(3), options: options) #expect(wideRepriced.summary?.totalCostUSD == newDailyCost * 2) } @Test func `codex incremental cache preserves divergent total baseline`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 5, day: 18) let iso0 = env.isoString(for: day) let iso1 = env.isoString(for: day.addingTimeInterval(1)) let iso2 = env.isoString(for: day.addingTimeInterval(2)) let model = "openai/gpt-5.4" let sessionMeta: [String: Any] = [ "type": "session_meta", "timestamp": iso0, "payload": ["session_id": "divergent-session"], ] let turnContext = self.codexTurnContext(timestamp: iso0, model: model) let firstTokenCount = self.codexTokenCount( timestamp: iso1, model: model, total: (input: 50, cached: 0, output: 0), last: (input: 100, cached: 0, output: 0)) let fileURL = try env.writeCodexSessionFile( day: day, filename: "session.jsonl", contents: env.jsonl([sessionMeta, turnContext, firstTokenCount])) var options = CostUsageScanner.Options( codexSessionsRoot: env.codexSessionsRoot, claudeProjectsRoots: nil, cacheRoot: env.cacheRoot, codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) options.refreshMinIntervalSeconds = 0 let first = CostUsageScanner.loadDailyReport( provider: .codex, since: day, until: day, now: day, options: options) #expect(first.data.first?.totalTokens == 100) let secondTokenCount = self.codexTokenCount( timestamp: iso2, model: model, total: (input: 80, cached: 0, output: 0)) try env.jsonl([sessionMeta, turnContext, firstTokenCount, secondTokenCount]) .write(to: fileURL, atomically: true, encoding: .utf8) let second = CostUsageScanner.loadDailyReport( provider: .codex, since: day, until: day, now: day.addingTimeInterval(1), options: options) let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) let usage = cache.files.first { URL(fileURLWithPath: $0.key).lastPathComponent == fileURL.lastPathComponent }? .value #expect(second.data.first?.totalTokens == 130) #expect(usage?.lastTotals == nil) #expect(usage?.lastCountedTotals?.input == 130) #expect(usage?.lastRawTotalsBaseline?.input == 80) #expect(usage?.hasDivergentTotals == true) } @Test func `codex incremental cache migrates legacy rows before appending delta costs`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 5, day: 18) let olderDay = try env.makeLocalNoon(year: 2026, month: 5, day: 10) let olderDayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: olderDay) let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) let iso0 = env.isoString(for: day) let iso1 = env.isoString(for: day.addingTimeInterval(1)) let iso2 = env.isoString(for: day.addingTimeInterval(2)) let model = "gpt-5.4" let sessionMeta: [String: Any] = [ "type": "session_meta", "timestamp": iso0, "payload": ["session_id": "legacy-cost-session"], ] let turnContext = self.codexTurnContext(timestamp: iso0, model: model) let firstTokenCount = self.codexTokenCount( timestamp: iso1, model: model, total: (input: 10, cached: 0, output: 0)) let fileURL = try env.writeCodexSessionFile( day: day, filename: "session.jsonl", contents: env.jsonl([sessionMeta, turnContext, firstTokenCount])) var options = CostUsageScanner.Options( codexSessionsRoot: env.codexSessionsRoot, claudeProjectsRoots: nil, cacheRoot: env.cacheRoot, codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) options.refreshMinIntervalSeconds = 0 _ = CostUsageScanner.loadDailyReport( provider: .codex, since: day, until: day, now: day, options: options) var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) let path = try #require(cache.files.keys.first) var cachedUsage = try #require(cache.files[path]) #expect(cachedUsage.sessionId == "legacy-cost-session") #expect(cachedUsage.lastCountedTotals?.input == 10) cachedUsage.codexCostNanos = nil cachedUsage.codexRows = [ CostUsageScanner.CodexUsageRow( day: olderDayKey, model: CostUsagePricing.normalizeCodexModel(model), turnID: nil, input: 20, cached: 0, output: 0), CostUsageScanner.CodexUsageRow( day: dayKey, model: CostUsagePricing.normalizeCodexModel(model), turnID: nil, input: 10, cached: 0, output: 0), ] cache.files[path] = cachedUsage CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) let savedUsage = try #require(CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot).files[path]) #expect(savedUsage.codexRows?.map(\.day) == [olderDayKey, dayKey]) let secondTokenCount = self.codexTokenCount( timestamp: iso2, model: model,Lines 1–500 of 3478. Load more
import Foundation import Testing @testable import CodexBarCore // swiftlint:disable file_length // swiftlint:disable type_body_length struct CostUsageScannerBreakdownTests { private typealias Usage = (input: Int, cached: Int, output: Int) private func codexTurnContext(timestamp: String, model: String) -> [String: Any] { [ "type": "turn_context", "timestamp": timestamp, "payload": [ "model": model, ], ] } private func codexTokenCount( timestamp: String, model: String, total: Usage? = nil, last: Usage? = nil) -> [String: Any] { var info: [String: Any] = [ "model": model, ] if let total { info["total_token_usage"] = [ "input_tokens": total.input, "cached_input_tokens": total.cached, "output_tokens": total.output, ] } if let last { info["last_token_usage"] = [ "input_tokens": last.input, "cached_input_tokens": last.cached, "output_tokens": last.output, ] } return [ "type": "event_msg", "timestamp": timestamp, "payload": [ "type": "token_count", "info": info, ], ] } private func codexTokenCountWithoutModel(timestamp: String, last: Usage) -> [String: Any] { [ "type": "event_msg", "timestamp": timestamp, "payload": [ "type": "token_count", "info": [ "last_token_usage": [ "input_tokens": last.input, "cached_input_tokens": last.cached, "output_tokens": last.output, ], ], ], ] } private func oversizedCodexTurnContextLine(timestamp: String, model: String) -> String { let largeInstructions = String(repeating: "x", count: 300 * 1024) return #"{"type":"turn_context","timestamp":""# + timestamp + #"","payload":{"model":""# + model + #"","instructions":""# + largeInstructions + #""}}"# } private func oversizedCodexTurnContextInfoModelLine(timestamp: String, model: String) -> String { let largeInstructions = String(repeating: "x", count: 300 * 1024) return #"{"type":"turn_context","timestamp":""# + timestamp + #"","payload":{"empty":"","info":{"model":""# + model + #""},"instructions":""# + largeInstructions + #""}}"# } private func oversizedCodexTurnContextPromptOnlyLine(timestamp: String, promptModel: String) -> String { let prompt = #"example: {\"type\":\"turn_context\",\"payload\":{\"model\":\"\#(promptModel)\"}}"# + String(repeating: "x", count: 300 * 1024) return #"{"type":"turn_context","timestamp":""# + timestamp + #"","payload":{"instructions":""# + prompt + #""}}"# } @Test func `codex daily report parses token counts and caches`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2025, month: 12, day: 20) let iso0 = env.isoString(for: day) let iso1 = env.isoString(for: day.addingTimeInterval(1)) let iso2 = env.isoString(for: day.addingTimeInterval(2)) let model = "openai/gpt-5.2-codex" let turnContext: [String: Any] = [ "type": "turn_context", "timestamp": iso0, "payload": [ "model": model, ], ] let firstTokenCount: [String: Any] = [ "type": "event_msg", "timestamp": iso1, "payload": [ "type": "token_count", "info": [ "total_token_usage": [ "input_tokens": 100, "cached_input_tokens": 20, "output_tokens": 10, ], "model": model, ], ], ] let fileURL = try env.writeCodexSessionFile( day: day, filename: "session.jsonl", contents: env.jsonl([turnContext, firstTokenCount])) var options = CostUsageScanner.Options( codexSessionsRoot: env.codexSessionsRoot, claudeProjectsRoots: nil, cacheRoot: env.cacheRoot, codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) options.refreshMinIntervalSeconds = 0 let first = CostUsageScanner.loadDailyReport( provider: .codex, since: day, until: day, now: day, options: options) #expect(first.data.count == 1) #expect(first.data[0].modelsUsed == ["gpt-5.2-codex"]) #expect(first.data[0].modelBreakdowns == [ CostUsageDailyReport.ModelBreakdown( modelName: "gpt-5.2-codex", costUSD: first.data[0].costUSD, totalTokens: 110), ]) #expect(first.data[0].totalTokens == 110) #expect((first.data[0].costUSD ?? 0) > 0) let firstCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) #expect(firstCache.codexPricingKey?.hasPrefix("builtin-") == true) let secondTokenCount: [String: Any] = [ "type": "event_msg", "timestamp": iso2, "payload": [ "type": "token_count", "info": [ "total_token_usage": [ "input_tokens": 160, "cached_input_tokens": 40, "output_tokens": 16, ], "model": model, ], ], ] try env.jsonl([turnContext, firstTokenCount, secondTokenCount]) .write(to: fileURL, atomically: true, encoding: .utf8) let second = CostUsageScanner.loadDailyReport( provider: .codex, since: day, until: day, now: day, options: options) #expect(second.data.count == 1) #expect(second.data[0].modelsUsed == ["gpt-5.2-codex"]) #expect(second.data[0].totalTokens == 176) #expect((second.data[0].costUSD ?? 0) > (first.data[0].costUSD ?? 0)) } @Test func `codex incremental append falls back to rescan when fork metadata appears late`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 3, day: 11) let iso0 = env.isoString(for: day) let iso1 = env.isoString(for: day.addingTimeInterval(1)) let iso2 = env.isoString(for: day.addingTimeInterval(2)) let iso3 = env.isoString(for: day.addingTimeInterval(3)) let model = "gpt-5.4" let sessionMeta: [String: Any] = [ "type": "session_meta", "timestamp": iso0, "payload": ["id": "late-fork-child"], ] let turnContext = self.codexTurnContext(timestamp: iso0, model: model) let firstTokenCount = self.codexTokenCount( timestamp: iso1, model: model, total: (input: 10, cached: 0, output: 0), last: (input: 10, cached: 0, output: 0)) let fileURL = try env.writeCodexSessionFile( day: day, filename: "late-fork-child.jsonl", contents: env.jsonl([sessionMeta, turnContext, firstTokenCount])) var options = CostUsageScanner.Options( codexSessionsRoot: env.codexSessionsRoot, claudeProjectsRoots: nil, cacheRoot: env.cacheRoot, codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) options.refreshMinIntervalSeconds = 0 let first = CostUsageScanner.loadDailyReport( provider: .codex, since: day, until: day, now: day, options: options) #expect(first.data.first?.totalTokens == 10) let lateForkMeta: [String: Any] = [ "type": "session_meta", "timestamp": iso2, "payload": [ "id": "late-fork-child", "forked_from_id": "missing-parent", "timestamp": iso2, ], ] let replayedForkUsage = self.codexTokenCount( timestamp: iso3, model: model, total: (input: 1000, cached: 900, output: 100), last: (input: 1000, cached: 900, output: 100)) let handle = try FileHandle(forWritingTo: fileURL) try handle.seekToEnd() try handle.write(contentsOf: Data(("\n" + env.jsonl([lateForkMeta, replayedForkUsage])).utf8)) try handle.close() let second = CostUsageScanner.loadDailyReport( provider: .codex, since: day, until: day, now: day, options: options) #expect(second.data.first?.totalTokens == 10) } @Test func `codex daily report reprices cached sessions when models dev pricing changes`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) let iso0 = env.isoString(for: day) let iso1 = env.isoString(for: day.addingTimeInterval(1)) let olderDay = try env.makeLocalNoon(year: 2026, month: 5, day: 5) let olderFileURL = try env.writeCodexSessionFile( day: olderDay, filename: "older-session.jsonl", contents: env.jsonl([ self.codexTurnContext(timestamp: env.isoString(for: olderDay), model: "custom-codex-model"), self.codexTokenCount( timestamp: env.isoString(for: olderDay.addingTimeInterval(1)), model: "custom-codex-model", last: (input: 100, cached: 20, output: 10)), ])) try FileManager.default.setAttributes( [.modificationDate: olderDay], ofItemAtPath: olderFileURL.path) let model = "custom-codex-model" _ = try env.writeCodexSessionFile( day: day, filename: "session.jsonl", contents: env.jsonl([ self.codexTurnContext(timestamp: iso0, model: model), self.codexTokenCount(timestamp: iso1, model: model, last: (input: 100, cached: 20, output: 10)), ])) try ModelsDevCache.save( catalog: Self.modelsDevCatalog(model: model, input: 1, output: 2, cacheRead: 0.5), fetchedAt: Date(timeIntervalSince1970: 1), cacheRoot: env.cacheRoot) var options = CostUsageScanner.Options( codexSessionsRoot: env.codexSessionsRoot, claudeProjectsRoots: nil, cacheRoot: env.cacheRoot, codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) options.refreshMinIntervalSeconds = 0 let first = CostUsageScanner.loadDailyReport( provider: .codex, since: olderDay, until: day, now: day, options: options) let oldDailyCost = (80.0 / 1_000_000.0) + (20.0 * 0.5 / 1_000_000.0) + (10.0 * 2.0 / 1_000_000.0) #expect(first.summary?.totalCostUSD == oldDailyCost * 2) try ModelsDevCache.save( catalog: Self.modelsDevCatalog(model: model, input: 1, output: 2, cacheRead: 0.5), fetchedAt: Date(timeIntervalSince1970: 2), cacheRoot: env.cacheRoot) options.refreshMinIntervalSeconds = 60 let samePricing = CostUsageScanner.loadDailyReport( provider: .codex, since: day, until: day, now: day.addingTimeInterval(1), options: options) let samePricingCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) #expect(samePricing.summary?.totalCostUSD == oldDailyCost) #expect(samePricingCache.scanSinceKey == "2026-05-04") try ModelsDevCache.save( catalog: Self.modelsDevCatalog(model: model, input: 10, output: 20, cacheRead: 5), fetchedAt: Date(timeIntervalSince1970: 2), cacheRoot: env.cacheRoot) options.refreshMinIntervalSeconds = 60 let narrowRepriced = CostUsageScanner.loadDailyReport( provider: .codex, since: day, until: day, now: day.addingTimeInterval(2), options: options) let newDailyCost = (80.0 * 10.0 / 1_000_000.0) + (20.0 * 5.0 / 1_000_000.0) + (10.0 * 20.0 / 1_000_000.0) #expect(narrowRepriced.summary?.totalCostUSD == newDailyCost) let wideRepriced = CostUsageScanner.loadDailyReport( provider: .codex, since: olderDay, until: day, now: day.addingTimeInterval(3), options: options) #expect(wideRepriced.summary?.totalCostUSD == newDailyCost * 2) } @Test func `codex incremental cache preserves divergent total baseline`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 5, day: 18) let iso0 = env.isoString(for: day) let iso1 = env.isoString(for: day.addingTimeInterval(1)) let iso2 = env.isoString(for: day.addingTimeInterval(2)) let model = "openai/gpt-5.4" let sessionMeta: [String: Any] = [ "type": "session_meta", "timestamp": iso0, "payload": ["session_id": "divergent-session"], ] let turnContext = self.codexTurnContext(timestamp: iso0, model: model) let firstTokenCount = self.codexTokenCount( timestamp: iso1, model: model, total: (input: 50, cached: 0, output: 0), last: (input: 100, cached: 0, output: 0)) let fileURL = try env.writeCodexSessionFile( day: day, filename: "session.jsonl", contents: env.jsonl([sessionMeta, turnContext, firstTokenCount])) var options = CostUsageScanner.Options( codexSessionsRoot: env.codexSessionsRoot, claudeProjectsRoots: nil, cacheRoot: env.cacheRoot, codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) options.refreshMinIntervalSeconds = 0 let first = CostUsageScanner.loadDailyReport( provider: .codex, since: day, until: day, now: day, options: options) #expect(first.data.first?.totalTokens == 100) let secondTokenCount = self.codexTokenCount( timestamp: iso2, model: model, total: (input: 80, cached: 0, output: 0)) try env.jsonl([sessionMeta, turnContext, firstTokenCount, secondTokenCount]) .write(to: fileURL, atomically: true, encoding: .utf8) let second = CostUsageScanner.loadDailyReport( provider: .codex, since: day, until: day, now: day.addingTimeInterval(1), options: options) let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) let usage = cache.files.first { URL(fileURLWithPath: $0.key).lastPathComponent == fileURL.lastPathComponent }? .value #expect(second.data.first?.totalTokens == 130) #expect(usage?.lastTotals == nil) #expect(usage?.lastCountedTotals?.input == 130) #expect(usage?.lastRawTotalsBaseline?.input == 80) #expect(usage?.hasDivergentTotals == true) } @Test func `codex incremental cache migrates legacy rows before appending delta costs`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 5, day: 18) let olderDay = try env.makeLocalNoon(year: 2026, month: 5, day: 10) let olderDayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: olderDay) let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) let iso0 = env.isoString(for: day) let iso1 = env.isoString(for: day.addingTimeInterval(1)) let iso2 = env.isoString(for: day.addingTimeInterval(2)) let model = "gpt-5.4" let sessionMeta: [String: Any] = [ "type": "session_meta", "timestamp": iso0, "payload": ["session_id": "legacy-cost-session"], ] let turnContext = self.codexTurnContext(timestamp: iso0, model: model) let firstTokenCount = self.codexTokenCount( timestamp: iso1, model: model, total: (input: 10, cached: 0, output: 0)) let fileURL = try env.writeCodexSessionFile( day: day, filename: "session.jsonl", contents: env.jsonl([sessionMeta, turnContext, firstTokenCount])) var options = CostUsageScanner.Options( codexSessionsRoot: env.codexSessionsRoot, claudeProjectsRoots: nil, cacheRoot: env.cacheRoot, codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) options.refreshMinIntervalSeconds = 0 _ = CostUsageScanner.loadDailyReport( provider: .codex, since: day, until: day, now: day, options: options) var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) let path = try #require(cache.files.keys.first) var cachedUsage = try #require(cache.files[path]) #expect(cachedUsage.sessionId == "legacy-cost-session") #expect(cachedUsage.lastCountedTotals?.input == 10) cachedUsage.codexCostNanos = nil cachedUsage.codexRows = [ CostUsageScanner.CodexUsageRow( day: olderDayKey, model: CostUsagePricing.normalizeCodexModel(model), turnID: nil, input: 20, cached: 0, output: 0), CostUsageScanner.CodexUsageRow( day: dayKey, model: CostUsagePricing.normalizeCodexModel(model), turnID: nil, input: 10, cached: 0, output: 0), ] cache.files[path] = cachedUsage CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) let savedUsage = try #require(CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot).files[path]) #expect(savedUsage.codexRows?.map(\.day) == [olderDayKey, dayKey]) let secondTokenCount = self.codexTokenCount( timestamp: iso2, model: model,