diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index a27b750991..b4eeb35463 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 = "f1fe17eb233c9f4d" + static let value = "a231538286ac81c9" } diff --git a/Sources/CodexBarCore/Providers/Codex/CodexLineageLedger.swift b/Sources/CodexBarCore/Providers/Codex/CodexLineageLedger.swift index ad798a76a3..a2053a539a 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexLineageLedger.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexLineageLedger.swift @@ -22,8 +22,16 @@ enum CodexLineageLedger { struct Observation: Equatable, Sendable { let timestamp: String + let model: String let last: Totals let total: Totals + + init(timestamp: String, model: String = CostUsagePricing.codexUnattributedModel, last: Totals, total: Totals) { + self.timestamp = timestamp + self.model = CostUsagePricing.normalizeCodexModel(model) + self.last = last + self.total = total + } } struct Document: Equatable, Sendable { @@ -38,11 +46,24 @@ enum CodexLineageLedger { struct Report: Equatable, Sendable { let utcDays: [String: Totals] let localDays: [String: Totals] + let utcRows: [DailyRow] + let localRows: [DailyRow] let componentCount: Int let acceptedObservationCount: Int let duplicateObservationCount: Int } + struct DailyRow: Equatable, Sendable { + let day: String + let model: String + let totals: Totals + let costUSD: Double? + + var isPriced: Bool { + self.costUSD != nil + } + } + enum LedgerError: Error, Equatable { case emptyOwnerID case invalidTimestamp(String) @@ -70,28 +91,44 @@ enum CodexLineageLedger { physicalObservationCount += 1 let date = try Self.date(from: observation.timestamp) let fingerprint = Fingerprint(last: observation.last, total: observation.total) - if let existing = accepted[fingerprint], existing.date <= date { - continue + if let existing = accepted[fingerprint] { + if existing.date < date { + continue + } + if existing.date == date, + !Self.shouldPreferModel(observation.model, over: existing.model) + { + continue + } } - accepted[fingerprint] = AcceptedObservation(date: date, last: observation.last) + accepted[fingerprint] = AcceptedObservation( + date: date, + model: observation.model, + last: observation.last) } acceptedByComponent[componentID] = accepted } var utcDays: [String: Totals] = [:] var localDays: [String: Totals] = [:] + var utcRows: [DailyRowKey: DailyRowValue] = [:] + var localRows: [DailyRowKey: DailyRowValue] = [:] var acceptedObservationCount = 0 for accepted in acceptedByComponent.values { acceptedObservationCount += accepted.count for observation in accepted.values { Self.add(observation.last, on: observation.date, timeZone: .gmt, to: &utcDays) Self.add(observation.last, on: observation.date, timeZone: localTimeZone, to: &localDays) + Self.addRow(observation, timeZone: .gmt, to: &utcRows) + Self.addRow(observation, timeZone: localTimeZone, to: &localRows) } } return Report( utcDays: utcDays, localDays: localDays, + utcRows: Self.dailyRows(from: utcRows), + localRows: Self.dailyRows(from: localRows), componentCount: Set(documents.map { graph.find($0.ownerID) }).count, acceptedObservationCount: acceptedObservationCount, duplicateObservationCount: physicalObservationCount - acceptedObservationCount) @@ -104,14 +141,37 @@ enum CodexLineageLedger { private struct AcceptedObservation { let date: Date + let model: String let last: Totals } + private struct DailyRowKey: Hashable { + let day: String + let model: String + } + + private struct DailyRowValue { + var totals: Totals = .zero + var costUSD = 0.0 + var isPriced = true + } + private static func nonEmpty(_ value: String?) -> String? { guard let value, !value.isEmpty else { return nil } return value } + /// Duplicate copies can carry different model evidence. Keep the earliest physical copy, + /// then resolve equal-time ties deterministically while preferring attributable evidence. + private static func shouldPreferModel(_ candidate: String, over existing: String) -> Bool { + let candidateIsUnknown = CostUsagePricing.isCodexUnattributedModel(candidate) + let existingIsUnknown = CostUsagePricing.isCodexUnattributedModel(existing) + if candidateIsUnknown != existingIsUnknown { + return !candidateIsUnknown + } + return candidate < existing + } + private static func date(from timestamp: String) throws -> Date { guard let date = CostUsageScanner.dateFromTimestamp(timestamp) else { throw LedgerError.invalidTimestamp(timestamp) @@ -135,6 +195,44 @@ enum CodexLineageLedger { days[key] = dayTotals } + private static func addRow( + _ observation: AcceptedObservation, + timeZone: TimeZone, + to rows: inout [DailyRowKey: DailyRowValue]) + { + let key = DailyRowKey(day: self.dayKey(for: observation.date, timeZone: timeZone), model: observation.model) + var value = rows[key] ?? DailyRowValue() + value.totals.add(observation.last) + if let cost = CostUsagePricing.codexCostUSD( + model: observation.model, + inputTokens: observation.last.input, + cachedInputTokens: observation.last.cached, + outputTokens: observation.last.output) + { + value.costUSD += cost + } else { + value.isPriced = false + } + rows[key] = value + } + + private static func dailyRows(from rows: [DailyRowKey: DailyRowValue]) -> [DailyRow] { + rows.map { key, value in + DailyRow( + day: key.day, + model: key.model, + totals: value.totals, + costUSD: value.isPriced ? value.costUSD : nil) + }.sorted { ($0.day, $0.model) < ($1.day, $1.model) } + } + + private static func dayKey(for date: Date, timeZone: TimeZone) -> String { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + let components = calendar.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0) + } + private struct DisjointSet { private var parents: [String: String] = [:] diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index b52b9e4bac..aa7f0cc51b 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -1685,12 +1685,14 @@ enum CostUsageScanner { return nil } + // swiftlint:disable:next cyclomatic_complexity private static func parseCodexTokenSnapshots( fileURL: URL, checkCancellation: CancellationCheck? = nil) throws -> CodexParsedTokenEvidence { var sessionId: String? var forkedFromId: String? + var currentModel: String? var accumulator = CodexSnapshotAccumulator() var snapshots: [CodexTimestampedTotals] = [] var observations: [CodexLineageLedger.Observation] = [] @@ -1708,7 +1710,12 @@ enum CostUsageScanner { return date } - func appendSnapshot(timestamp: String, last: CostUsageCodexTotals?, total: CostUsageCodexTotals?) { + func appendSnapshot( + timestamp: String, + model: String?, + last: CostUsageCodexTotals?, + total: CostUsageCodexTotals?) + { guard last != nil || total != nil else { return } let counted = accumulator.apply(last: last, total: total) snapshots.append(CodexTimestampedTotals( @@ -1718,6 +1725,9 @@ enum CostUsageScanner { if let last, let total { observations.append(CodexLineageLedger.Observation( timestamp: timestamp, + model: Self.codexModelEvidence(model) + ?? Self.codexModelEvidence(currentModel) + ?? CostUsagePricing.codexUnattributedModel, last: Self.lineageTotals(last), total: Self.lineageTotals(total))) } @@ -1741,8 +1751,16 @@ enum CostUsageScanner { forkedFromId = metadata.forkedFromId } case let .tokenCount(record): - appendSnapshot(timestamp: record.timestamp, last: record.last, total: record.total) - case .turnContext, .taskStarted: + appendSnapshot( + timestamp: record.timestamp, + model: record.model, + last: record.last, + total: record.total) + case let .turnContext(model): + if let model { + currentModel = model + } + case .taskStarted: break } return @@ -1768,6 +1786,20 @@ enum CostUsageScanner { return } + if obj["type"] as? String == "turn_context" { + let payload = obj["payload"] as? [String: Any] + let info = payload?["info"] as? [String: Any] + if let model = Self.codexTurnContextModel( + payloadModel: payload?["model"] as? String, + payloadModelName: payload?["model_name"] as? String, + infoModel: info?["model"] as? String, + infoModelName: info?["model_name"] as? String) + { + currentModel = model + } + return + } + guard obj["type"] as? String == "event_msg" else { return } guard let payload = obj["payload"] as? [String: Any] else { return } guard payload["type"] as? String == "token_count" else { return } @@ -1793,7 +1825,11 @@ enum CostUsageScanner { cached: max(0, toInt($0["cached_input_tokens"] ?? $0["cache_read_input_tokens"])), output: max(0, toInt($0["output_tokens"]))) } - appendSnapshot(timestamp: timestamp, last: last, total: total) + let model = Self.codexModelEvidence(info["model"] as? String) + ?? Self.codexModelEvidence(info["model_name"] as? String) + ?? Self.codexModelEvidence(payload["model"] as? String) + ?? Self.codexModelEvidence(obj["model"] as? String) + appendSnapshot(timestamp: timestamp, model: model, last: last, total: total) } }) } catch is CancellationError { diff --git a/Tests/CodexBarTests/CodexLineageLedgerTests.swift b/Tests/CodexBarTests/CodexLineageLedgerTests.swift index 9a6c46c808..aca6368d34 100644 --- a/Tests/CodexBarTests/CodexLineageLedgerTests.swift +++ b/Tests/CodexBarTests/CodexLineageLedgerTests.swift @@ -12,6 +12,7 @@ struct CodexLineageLedgerTests { "rollout-2026-07-09T12-00-00-\(ownerID).jsonl") let contents = [ #"{"type":"session_meta","payload":{"id":"metadata-id","forked_from_id":"parent-id"}}"#, + #"{"type":"turn_context","payload":{"model":"gpt-5.4"}}"#, Self.tokenCountLine( timestamp: "2026-07-09T12:00:00Z", last: (input: 100, cached: 40, output: 10), @@ -31,10 +32,116 @@ struct CodexLineageLedgerTests { #expect(document.metadataSessionID == "metadata-id") #expect(document.parentSessionID == "parent-id") #expect(document.observations.count == 2) + #expect(document.observations[0].model == "gpt-5.4") #expect(document.observations[0].last == .init(input: 100, cached: 40, output: 10)) #expect(document.observations[1].total == .init(input: 150, cached: 60, output: 15)) } + @Test + func `daily rows preserve model token and pricing dimensions`() throws { + let priced = Self.observation( + timestamp: "2026-07-10T02:00:00Z", + model: "gpt-5.4", + input: 100, + cached: 40, + output: 10, + totalInput: 100) + let unpriced = Self.observation( + timestamp: "2026-07-10T03:00:00Z", + model: "future-model", + input: 50, + output: 5, + totalInput: 150) + + let report = try CodexLineageLedger.reconcile( + documents: [Self.document(owner: "root", observations: [priced, unpriced])], + localTimeZone: #require(TimeZone(identifier: "America/New_York"))) + + #expect(report.utcRows.map(\.day) == ["2026-07-10", "2026-07-10"]) + #expect(report.localRows.map(\.day) == ["2026-07-09", "2026-07-09"]) + #expect(report.utcRows[0].model == "future-model") + #expect(report.utcRows[0].totals == .init(input: 50, cached: 0, output: 5)) + #expect(report.utcRows[0].isPriced == false) + #expect(report.utcRows[1].model == "gpt-5.4") + #expect(report.utcRows[1].isPriced) + #expect(report.utcRows[1].costUSD != nil) + } + + @Test + func `token event model overrides older turn context`() throws { + let environment = try CostUsageTestEnvironment() + let ownerID = "11111111-1111-4111-8111-111111111111" + let file = environment.codexSessionsRoot.appendingPathComponent( + "rollout-2026-07-09T12-00-00-\(ownerID).jsonl") + let contents = [ + #"{"type":"turn_context","payload":{"model":"gpt-5.4-mini"}}"#, + Self.tokenCountLine( + timestamp: "2026-07-09T12:00:00Z", + model: "gpt-5.4", + last: (input: 100, cached: 40, output: 10), + total: (input: 100, cached: 40, output: 10)), + ].joined(separator: "\n") + try FileManager.default.createDirectory(at: environment.codexSessionsRoot, withIntermediateDirectories: true) + try contents.write(to: file, atomically: true, encoding: .utf8) + + let document = try CostUsageScanner.parseCodexLineageDocument(fileURL: file) + + #expect(document.observations.map(\.model) == ["gpt-5.4"]) + } + + @Test + func `equal-time duplicate prefers attributed model deterministically`() throws { + let unknown = Self.observation(timestamp: "2026-07-10T03:00:00Z", input: 50, totalInput: 50) + let attributed = Self.observation( + timestamp: "2026-07-10T03:00:00Z", + model: "gpt-5.4", + input: 50, + totalInput: 50) + + let report = try CodexLineageLedger.reconcile( + documents: [ + Self.document(owner: "child", parent: "root", observations: [unknown]), + Self.document(owner: "root", observations: [attributed]), + ], + localTimeZone: .gmt) + + #expect(report.acceptedObservationCount == 1) + #expect(report.utcRows.map(\.model) == ["gpt-5.4"]) + #expect(report.utcRows[0].isPriced) + } + + @Test + func `daily rows price long context observations independently`() throws { + let first = Self.observation( + timestamp: "2026-07-10T03:00:00Z", + model: "gpt-5.4", + input: 272_001, + cached: 100_000, + output: 5, + totalInput: 272_001) + let second = Self.observation( + timestamp: "2026-07-10T04:00:00Z", + model: "gpt-5.4", + input: 100, + output: 5, + totalInput: 272_101) + let expected = try #require(CostUsagePricing.codexCostUSD( + model: "gpt-5.4", + inputTokens: 272_001, + cachedInputTokens: 100_000, + outputTokens: 5)) + #require(CostUsagePricing.codexCostUSD( + model: "gpt-5.4", + inputTokens: 100, + cachedInputTokens: 0, + outputTokens: 5)) + + let report = try CodexLineageLedger.reconcile( + documents: [Self.document(owner: "root", observations: [first, second])], + localTimeZone: .gmt) + + #expect(try abs(#require(report.utcRows.first?.costUSD) - expected) < 0.000001) + } + @Test func `ledger document parsing propagates cancellation`() throws { let environment = try CostUsageTestEnvironment() @@ -217,6 +324,7 @@ struct CodexLineageLedgerTests { private static func observation( timestamp: String, + model: String = CostUsagePricing.codexUnattributedModel, input: Int, cached: Int = 0, output: Int = 0, @@ -224,6 +332,7 @@ struct CodexLineageLedgerTests { { .init( timestamp: timestamp, + model: model, last: .init(input: input, cached: cached, output: output), total: .init(input: totalInput, cached: cached, output: output)) } @@ -245,12 +354,14 @@ struct CodexLineageLedgerTests { private static func tokenCountLine( timestamp: String, + model: String? = nil, last: (input: Int, cached: Int, output: Int), total: (input: Int, cached: Int, output: Int)) -> String { - #"{"type":"event_msg","timestamp":"\#(timestamp)","payload":{"type":"token_count","info":{"# + let modelJSON = model.map { #", "model":"\#($0)""# } ?? "" + return #"{"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)},"total_token_usage":{"input_tokens":\#(total.input),"# - + #""cached_input_tokens":\#(total.cached),"output_tokens":\#(total.output)}}}}"# + + #""cached_input_tokens":\#(total.cached),"output_tokens":\#(total.output)}\#(modelJSON)}}}"# } }