diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index 9c77cf4ef7..5852d05ac9 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -151,7 +151,7 @@ struct CostHistoryChartMenuView: View { var peak: (key: String, costUSD: Double)? var maxCostUSD: Double = 0 for entry in sorted { - guard let costUSD = entry.costUSD, costUSD > 0 else { continue } + guard let costUSD = entry.costUSD, costUSD >= 0 else { continue } guard let date = self.dateFromDayKey(entry.date) else { continue } let point = Point(date: date, costUSD: costUSD, totalTokens: entry.totalTokens) points.append(point) @@ -310,16 +310,18 @@ struct CostHistoryChartMenuView: View { guard let entry = model.entriesByDateKey[key] else { return nil } guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return nil } let parts = breakdown - .compactMap { item -> (name: String, costUSD: Double)? in - guard let costUSD = item.costUSD, costUSD > 0 else { return nil } - return (UsageFormatter.modelDisplayName(item.modelName), costUSD) + .compactMap { item -> (name: String, detail: String, costUSD: Double)? in + guard let costUSD = item.costUSD else { return nil } + let name = UsageFormatter.modelDisplayName(item.modelName) + guard let detail = UsageFormatter.modelCostDetail(item.modelName, costUSD: costUSD) else { return nil } + return (name, detail, costUSD) } .sorted { lhs, rhs in if lhs.costUSD == rhs.costUSD { return lhs.name < rhs.name } return lhs.costUSD > rhs.costUSD } .prefix(3) - .map { "\($0.name) \(UsageFormatter.usdString($0.costUSD))" } + .map { "\($0.name) \($0.detail)" } guard !parts.isEmpty else { return nil } return "Top: \(parts.joined(separator: " ยท "))" } diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index 226d435698..b03b18500e 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -206,6 +206,15 @@ public enum UsageFormatter { return cleaned.isEmpty ? raw : cleaned } + public static func modelCostDetail(_ model: String, costUSD: Double?) -> String? { + if let label = CostUsagePricing.codexDisplayLabel(model: model) { + return label + } + + guard let costUSD else { return nil } + return self.usdString(costUSD) + } + /// Cleans a provider plan string: strip ANSI/bracket noise, drop boilerplate words, collapse whitespace, and /// ensure a leading capital if the result starts lowercase. public static func cleanPlanName(_ text: String) -> String { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index e56131a416..2ea800cb53 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -1,6 +1,15 @@ import Foundation enum CostUsageCacheIO { + private static func artifactVersion(for provider: UsageProvider) -> Int { + switch provider { + case .codex: + 2 + default: + 1 + } + } + private static func defaultCacheRoot() -> URL { let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! return root.appendingPathComponent("CodexBar", isDirectory: true) @@ -8,9 +17,10 @@ enum CostUsageCacheIO { static func cacheFileURL(provider: UsageProvider, cacheRoot: URL? = nil) -> URL { let root = cacheRoot ?? self.defaultCacheRoot() + let artifactVersion = self.artifactVersion(for: provider) return root .appendingPathComponent("cost-usage", isDirectory: true) - .appendingPathComponent("\(provider.rawValue)-v1.json", isDirectory: false) + .appendingPathComponent("\(provider.rawValue)-v\(artifactVersion).json", isDirectory: false) } static func load(provider: UsageProvider, cacheRoot: URL? = nil) -> CostUsageCache { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift index 5105f97593..02ca71bf2e 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift @@ -33,12 +33,15 @@ enum CostUsageJsonl { guard !segment.isEmpty else { return } lineBytes += segment.count guard !truncated else { return } + + let remainingPrefix = max(0, prefixBytes - current.count) + if remainingPrefix > 0 { + current.append(contentsOf: segment.prefix(remainingPrefix)) + } + if lineBytes > maxLineBytes || lineBytes > prefixBytes { truncated = true - current.removeAll(keepingCapacity: true) - return } - current.append(contentsOf: segment) } func flushLine() { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index ffc7b1b91a..8e7b060923 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -4,7 +4,32 @@ enum CostUsagePricing { struct CodexPricing: Sendable { let inputCostPerToken: Double let outputCostPerToken: Double - let cacheReadInputCostPerToken: Double + let cacheReadInputCostPerToken: Double? + let thresholdTokens: Int? + let inputCostPerTokenAboveThreshold: Double? + let outputCostPerTokenAboveThreshold: Double? + let cacheReadInputCostPerTokenAboveThreshold: Double? + let displayLabel: String? + + init( + inputCostPerToken: Double, + outputCostPerToken: Double, + cacheReadInputCostPerToken: Double?, + thresholdTokens: Int? = nil, + inputCostPerTokenAboveThreshold: Double? = nil, + outputCostPerTokenAboveThreshold: Double? = nil, + cacheReadInputCostPerTokenAboveThreshold: Double? = nil, + displayLabel: String? = nil) + { + self.inputCostPerToken = inputCostPerToken + self.outputCostPerToken = outputCostPerToken + self.cacheReadInputCostPerToken = cacheReadInputCostPerToken + self.thresholdTokens = thresholdTokens + self.inputCostPerTokenAboveThreshold = inputCostPerTokenAboveThreshold + self.outputCostPerTokenAboveThreshold = outputCostPerTokenAboveThreshold + self.cacheReadInputCostPerTokenAboveThreshold = cacheReadInputCostPerTokenAboveThreshold + self.displayLabel = displayLabel + } } struct ClaudePricing: Sendable { @@ -24,31 +49,123 @@ enum CostUsagePricing { "gpt-5": CodexPricing( inputCostPerToken: 1.25e-6, outputCostPerToken: 1e-5, - cacheReadInputCostPerToken: 1.25e-7), + cacheReadInputCostPerToken: 1.25e-7, + displayLabel: nil), + "gpt-5-chat": CodexPricing( + inputCostPerToken: 1.25e-6, + outputCostPerToken: 1e-5, + cacheReadInputCostPerToken: 1.25e-7, + displayLabel: nil), + "gpt-5-chat-latest": CodexPricing( + inputCostPerToken: 1.25e-6, + outputCostPerToken: 1e-5, + cacheReadInputCostPerToken: 1.25e-7, + displayLabel: nil), "gpt-5-codex": CodexPricing( inputCostPerToken: 1.25e-6, outputCostPerToken: 1e-5, - cacheReadInputCostPerToken: 1.25e-7), + cacheReadInputCostPerToken: 1.25e-7, + displayLabel: nil), + "gpt-5-codex-mini": CodexPricing( + inputCostPerToken: 2.5e-7, + outputCostPerToken: 2e-6, + cacheReadInputCostPerToken: 2.5e-8, + displayLabel: nil), + "gpt-5-mini": CodexPricing( + inputCostPerToken: 2.5e-7, + outputCostPerToken: 2e-6, + cacheReadInputCostPerToken: 2.5e-8, + displayLabel: nil), + "gpt-5-nano": CodexPricing( + inputCostPerToken: 5e-8, + outputCostPerToken: 4e-7, + cacheReadInputCostPerToken: 5e-9, + displayLabel: nil), + "gpt-5-pro": CodexPricing( + inputCostPerToken: 1.5e-5, + outputCostPerToken: 1.2e-4, + cacheReadInputCostPerToken: nil, + displayLabel: nil), "gpt-5.1": CodexPricing( inputCostPerToken: 1.25e-6, outputCostPerToken: 1e-5, - cacheReadInputCostPerToken: 1.25e-7), + cacheReadInputCostPerToken: 1.25e-7, + displayLabel: nil), + "gpt-5.1-chat-latest": CodexPricing( + inputCostPerToken: 1.25e-6, + outputCostPerToken: 1e-5, + cacheReadInputCostPerToken: 1.25e-7, + displayLabel: nil), + "gpt-5.1-codex": CodexPricing( + inputCostPerToken: 1.25e-6, + outputCostPerToken: 1e-5, + cacheReadInputCostPerToken: 1.25e-7, + displayLabel: nil), + "gpt-5.1-codex-max": CodexPricing( + inputCostPerToken: 1.25e-6, + outputCostPerToken: 1e-5, + cacheReadInputCostPerToken: 1.25e-7, + displayLabel: nil), + "gpt-5.1-codex-mini": CodexPricing( + inputCostPerToken: 2.5e-7, + outputCostPerToken: 2e-6, + cacheReadInputCostPerToken: 2.5e-8, + displayLabel: nil), "gpt-5.2": CodexPricing( inputCostPerToken: 1.75e-6, outputCostPerToken: 1.4e-5, - cacheReadInputCostPerToken: 1.75e-7), + cacheReadInputCostPerToken: 1.75e-7, + displayLabel: nil), + "gpt-5.2-chat": CodexPricing( + inputCostPerToken: 1.75e-6, + outputCostPerToken: 1.4e-5, + cacheReadInputCostPerToken: 1.75e-7, + displayLabel: nil), + "gpt-5.2-chat-latest": CodexPricing( + inputCostPerToken: 1.75e-6, + outputCostPerToken: 1.4e-5, + cacheReadInputCostPerToken: 1.75e-7, + displayLabel: nil), "gpt-5.2-codex": CodexPricing( inputCostPerToken: 1.75e-6, outputCostPerToken: 1.4e-5, - cacheReadInputCostPerToken: 1.75e-7), + cacheReadInputCostPerToken: 1.75e-7, + displayLabel: nil), + "gpt-5.2-pro": CodexPricing( + inputCostPerToken: 2.1e-5, + outputCostPerToken: 1.68e-4, + cacheReadInputCostPerToken: nil, + displayLabel: nil), + "gpt-5.3-codex": CodexPricing( + inputCostPerToken: 1.75e-6, + outputCostPerToken: 1.4e-5, + cacheReadInputCostPerToken: 1.75e-7, + displayLabel: nil), "gpt-5.3": CodexPricing( inputCostPerToken: 1.75e-6, outputCostPerToken: 1.4e-5, - cacheReadInputCostPerToken: 1.75e-7), - "gpt-5.3-codex": CodexPricing( + cacheReadInputCostPerToken: 1.75e-7, + displayLabel: nil), + "gpt-5.3-chat-latest": CodexPricing( inputCostPerToken: 1.75e-6, outputCostPerToken: 1.4e-5, - cacheReadInputCostPerToken: 1.75e-7), + cacheReadInputCostPerToken: 1.75e-7, + displayLabel: nil), + "gpt-5.3-codex-spark": CodexPricing( + inputCostPerToken: 0, + outputCostPerToken: 0, + cacheReadInputCostPerToken: 0, + displayLabel: "Research Preview"), + "gpt-5.4": CodexPricing( + inputCostPerToken: 2.5e-6, + outputCostPerToken: 1.5e-5, + cacheReadInputCostPerToken: 2.5e-7, + displayLabel: nil), + "gpt-5.4-pro": CodexPricing( + inputCostPerToken: 3e-5, + outputCostPerToken: 1.8e-4, + cacheReadInputCostPerToken: nil, + displayLabel: nil), ] private static let claude: [String: ClaudePricing] = [ @@ -165,17 +282,51 @@ enum CostUsagePricing { ] static func normalizeCodexModel(_ raw: String) -> String { - var trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.hasPrefix("openai/") { - trimmed = String(trimmed.dropFirst("openai/".count)) + var trimmed = self.displayCodexModel(raw) + if let snapshotBase = self.codexSnapshotBaseModel(trimmed) { + trimmed = snapshotBase + } + if self.codex[trimmed] != nil { + return trimmed } - if let codexRange = trimmed.range(of: "-codex") { + if let codexRange = trimmed.range(of: "-codex"), !trimmed.contains("-codex-mini") { let base = String(trimmed[.. String { + var trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("openai/") { + trimmed = String(trimmed.dropFirst("openai/".count)) + } + return trimmed + } + + private static func codexSnapshotBaseModel(_ raw: String) -> String? { + let patterns = [ + #"-\d{4}-\d{2}-\d{2}$"#, + #"-\d{8}$"#, + ] + + for pattern in patterns { + if let range = raw.range(of: pattern, options: .regularExpression) { + let base = String(raw[.. String? { + let key = self.normalizeCodexModel(model) + return self.codex[key]?.displayLabel + } + static func normalizeClaudeModel(_ raw: String) -> String { var trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.hasPrefix("anthropic.") { @@ -210,9 +361,28 @@ enum CostUsagePricing { guard let pricing = self.codex[key] else { return nil } let cached = min(max(0, cachedInputTokens), max(0, inputTokens)) let nonCached = max(0, inputTokens - cached) - return Double(nonCached) * pricing.inputCostPerToken - + Double(cached) * pricing.cacheReadInputCostPerToken - + Double(max(0, outputTokens)) * pricing.outputCostPerToken + let effectiveInputTokens = max(0, inputTokens) + let useAboveThresholdPricing = if let threshold = pricing.thresholdTokens { + effectiveInputTokens > threshold + } else { + false + } + let inputRate = useAboveThresholdPricing + ? (pricing.inputCostPerTokenAboveThreshold ?? pricing.inputCostPerToken) + : pricing.inputCostPerToken + let outputRate = useAboveThresholdPricing + ? (pricing.outputCostPerTokenAboveThreshold ?? pricing.outputCostPerToken) + : pricing.outputCostPerToken + let cacheRate = useAboveThresholdPricing + ? (pricing.cacheReadInputCostPerTokenAboveThreshold ?? pricing.cacheReadInputCostPerToken) + : pricing.cacheReadInputCostPerToken + + if cached > 0, cacheRate == nil { + return nil + } + return Double(nonCached) * inputRate + + Double(cached) * (cacheRate ?? 0) + + Double(max(0, outputTokens)) * outputRate } static func claudeCostUSD( diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Codex.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Codex.swift new file mode 100644 index 0000000000..54ce347619 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Codex.swift @@ -0,0 +1,359 @@ +import Foundation + +private struct CostUsageScannerCodexRecoveredPrefix: Sendable { + let type: String + let timestamp: String? + let sessionId: String? + let model: String? + let totalUsage: CostUsageCodexTotals? + let lastUsage: CostUsageCodexTotals? +} + +private struct CostUsageScannerCodexTokenUsageRecord { + let explicitModel: String? + let totalUsage: CostUsageCodexTotals? + let lastUsage: CostUsageCodexTotals? +} + +private struct CostUsageScannerCodexParseState { + var currentModel: String? + var previousTotals: CostUsageCodexTotals? + var sessionId: String? + var days: [String: [String: [Int]]] = [:] +} + +extension CostUsageScanner { + private static func regexCapture(_ pattern: String, in text: String) -> String? { + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let range = NSRange(text.startIndex.. Int? { + guard let range = text.range(of: anchor) else { return nil } + var idx = range.upperBound + var digits = "" + while idx < text.endIndex, let ascii = text[idx].asciiValue, ascii >= 48, ascii <= 57 { + digits.append(text[idx]) + idx = text.index(after: idx) + } + guard !digits.isEmpty else { return nil } + return Int(digits) + } + + private static func recoverCodexTokenUsage(blockName: String, in text: String) -> CostUsageCodexTotals? { + guard let range = text.range(of: "\"\(blockName)\":{") else { return nil } + let suffix = text[range.upperBound...] + guard let end = suffix.firstIndex(of: "}") else { return nil } + let block = String(suffix[.. String? { + if let text = String(bytes: bytes, encoding: .utf8) { + return text + } + + var trimmed = bytes + while !trimmed.isEmpty { + trimmed.removeLast() + if let text = String(bytes: trimmed, encoding: .utf8) { + return text + } + } + + return nil + } + + private static func recoverCodexPrefix(from bytes: Data) -> CostUsageScannerCodexRecoveredPrefix? { + guard let text = recoverCodexPrefixText(from: bytes) else { return nil } + guard let type = Self.regexCapture("^\\{\"type\":\"([^\"]+)\"", in: text) else { return nil } + + let timestamp = Self.regexCapture("\"timestamp\":\"([^\"]+)\"", in: text) + + let sessionIdPatterns = [ + "\"payload\":\\{.*?\"session_id\":\"([^\"]+)\"", + "\"payload\":\\{.*?\"sessionId\":\"([^\"]+)\"", + "\"payload\":\\{.*?\"id\":\"([^\"]+)\"", + "\"session_id\":\"([^\"]+)\"", + "\"sessionId\":\"([^\"]+)\"", + "\"id\":\"([^\"]+)\"", + ] + let sessionId = sessionIdPatterns.lazy.compactMap { Self.regexCapture($0, in: text) }.first + + let modelPatterns: [String] = switch type { + case "turn_context": + [ + "\"payload\":\\{.*?\"model\":\"([^\"]+)\"", + "\"payload\":\\{.*?\"info\":\\{.*?\"model\":\"([^\"]+)\"", + "(?:^|,)\"model\":\"([^\"]+)\"", + ] + case "event_msg": + [ + "\"payload\":\\{.*?\"info\":\\{.*?\"model\":\"([^\"]+)\"", + "\"payload\":\\{.*?\"info\":\\{.*?\"model_name\":\"([^\"]+)\"", + "\"payload\":\\{.*?\"model\":\"([^\"]+)\"", + "(?:^|,)\"model\":\"([^\"]+)\"", + ] + default: + ["(?:^|,)\"model\":\"([^\"]+)\""] + } + let model = modelPatterns.lazy.compactMap { Self.regexCapture($0, in: text) }.first + + let totalUsage = type == "event_msg" && text.contains("\"token_count\"") + ? Self.recoverCodexTokenUsage(blockName: "total_token_usage", in: text) + : nil + let lastUsage = type == "event_msg" && text.contains("\"token_count\"") + ? Self.recoverCodexTokenUsage(blockName: "last_token_usage", in: text) + : nil + + return CostUsageScannerCodexRecoveredPrefix( + type: type, + timestamp: timestamp, + sessionId: sessionId, + model: model, + totalUsage: totalUsage, + lastUsage: lastUsage) + } + + private static func shouldInspectCodexLine(_ line: CostUsageJsonl.Line) -> Bool { + if line.bytes.isEmpty { return false } + let hasRelevantType = line.bytes.containsAscii(#""type":"event_msg""#) + || line.bytes.containsAscii(#""type":"turn_context""#) + || line.bytes.containsAscii(#""type":"session_meta""#) + guard hasRelevantType else { return false } + if line.bytes.containsAscii(#""type":"event_msg""#), !line.bytes.containsAscii(#""token_count""#) { + return false + } + return true + } + + private static func addCodexUsage( + dayKey: String, + model: String, + usage: CostUsageCodexTotals, + range: CostUsageDayRange, + state: inout CostUsageScannerCodexParseState) + { + guard CostUsageDayRange.isInRange(dayKey: dayKey, since: range.scanSinceKey, until: range.scanUntilKey) + else { return } + let normModel = CostUsagePricing.normalizeCodexModel(model) + + var dayModels = state.days[dayKey] ?? [:] + var packed = dayModels[normModel] ?? [0, 0, 0] + packed[0] = (packed[safe: 0] ?? 0) + usage.input + packed[1] = (packed[safe: 1] ?? 0) + usage.cached + packed[2] = (packed[safe: 2] ?? 0) + usage.output + dayModels[normModel] = packed + state.days[dayKey] = dayModels + } + + private static func recordCodexTokenUsage( + dayKey: String, + usage: CostUsageScannerCodexTokenUsageRecord, + range: CostUsageDayRange, + state: inout CostUsageScannerCodexParseState) + { + if let explicitModel = usage.explicitModel { + state.currentModel = explicitModel + } + + let model = usage.explicitModel ?? state.currentModel ?? "gpt-5" + var deltaInput = 0 + var deltaCached = 0 + var deltaOutput = 0 + + if let totalUsage = usage.totalUsage { + let prev = state.previousTotals + deltaInput = max(0, totalUsage.input - (prev?.input ?? 0)) + deltaCached = max(0, totalUsage.cached - (prev?.cached ?? 0)) + deltaOutput = max(0, totalUsage.output - (prev?.output ?? 0)) + state.previousTotals = totalUsage + } else if let lastUsage = usage.lastUsage { + deltaInput = max(0, lastUsage.input) + deltaCached = max(0, lastUsage.cached) + deltaOutput = max(0, lastUsage.output) + } else { + return + } + + if deltaInput == 0, deltaCached == 0, deltaOutput == 0 { return } + let deltaUsage = CostUsageCodexTotals( + input: deltaInput, + cached: min(deltaCached, deltaInput), + output: deltaOutput) + Self.addCodexUsage( + dayKey: dayKey, + model: model, + usage: deltaUsage, + range: range, + state: &state) + } + + private static func parseCodexTruncatedLine( + _ line: CostUsageJsonl.Line, + range: CostUsageDayRange, + state: inout CostUsageScannerCodexParseState) + { + guard let recovered = recoverCodexPrefix(from: line.bytes) else { return } + + if recovered.type == "session_meta" { + if state.sessionId == nil { + state.sessionId = recovered.sessionId + } + return + } + + if let model = recovered.model { + state.currentModel = model + } + + guard let tsText = recovered.timestamp else { return } + guard let dayKey = Self.dayKeyFromTimestamp(tsText) ?? Self.dayKeyFromParsedISO(tsText) else { return } + + if recovered.type == "turn_context" { + return + } + + guard recovered.type == "event_msg" else { return } + guard line.bytes.containsAscii(#""token_count""#) else { return } + Self.recordCodexTokenUsage( + dayKey: dayKey, + usage: CostUsageScannerCodexTokenUsageRecord( + explicitModel: recovered.model, + totalUsage: recovered.totalUsage, + lastUsage: recovered.lastUsage), + range: range, + state: &state) + } + + private static func parseCodexFullLine( + _ line: CostUsageJsonl.Line, + range: CostUsageDayRange, + state: inout CostUsageScannerCodexParseState) + { + guard + let obj = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any], + let type = obj["type"] as? String + else { return } + + if type == "session_meta" { + if state.sessionId == nil { + let payload = obj["payload"] as? [String: Any] + state.sessionId = payload?["session_id"] as? String + ?? payload?["sessionId"] as? String + ?? payload?["id"] as? String + ?? obj["session_id"] as? String + ?? obj["sessionId"] as? String + ?? obj["id"] as? String + } + return + } + + guard let tsText = obj["timestamp"] as? String else { return } + guard let dayKey = Self.dayKeyFromTimestamp(tsText) ?? Self.dayKeyFromParsedISO(tsText) else { return } + + if type == "turn_context" { + if let payload = obj["payload"] as? [String: Any] { + if let model = payload["model"] as? String { + state.currentModel = model + } else if let info = payload["info"] as? [String: Any], let model = info["model"] as? String { + state.currentModel = model + } + } + return + } + + guard type == "event_msg" else { return } + guard let payload = obj["payload"] as? [String: Any] else { return } + guard (payload["type"] as? String) == "token_count" else { return } + + let info = payload["info"] as? [String: Any] + let modelFromInfo = info?["model"] as? String + ?? info?["model_name"] as? String + ?? payload["model"] as? String + ?? obj["model"] as? String + + func toInt(_ value: Any?) -> Int { + if let number = value as? NSNumber { return number.intValue } + return 0 + } + + let totalUsage = (info?["total_token_usage"] as? [String: Any]).map { + CostUsageCodexTotals( + input: toInt($0["input_tokens"]), + cached: toInt($0["cached_input_tokens"] ?? $0["cache_read_input_tokens"]), + output: toInt($0["output_tokens"])) + } + let lastUsage = (info?["last_token_usage"] as? [String: Any]).map { + CostUsageCodexTotals( + input: toInt($0["input_tokens"]), + cached: toInt($0["cached_input_tokens"] ?? $0["cache_read_input_tokens"]), + output: toInt($0["output_tokens"])) + } + + Self.recordCodexTokenUsage( + dayKey: dayKey, + usage: CostUsageScannerCodexTokenUsageRecord( + explicitModel: modelFromInfo, + totalUsage: totalUsage, + lastUsage: lastUsage), + range: range, + state: &state) + } + + private static func parseCodexLine( + _ line: CostUsageJsonl.Line, + range: CostUsageDayRange, + state: inout CostUsageScannerCodexParseState) + { + guard self.shouldInspectCodexLine(line) else { return } + if line.wasTruncated { + self.parseCodexTruncatedLine(line, range: range, state: &state) + } else { + self.parseCodexFullLine(line, range: range, state: &state) + } + } + + static func parseCodexFile( + fileURL: URL, + range: CostUsageDayRange, + startOffset: Int64 = 0, + initialModel: String? = nil, + initialTotals: CostUsageCodexTotals? = nil) -> CodexParseResult + { + var state = CostUsageScannerCodexParseState( + currentModel: initialModel, + previousTotals: initialTotals, + sessionId: nil, + days: [:]) + + let maxLineBytes = 256 * 1024 + let prefixBytes = 32 * 1024 + + let parsedBytes = (try? CostUsageJsonl.scan( + fileURL: fileURL, + offset: startOffset, + maxLineBytes: maxLineBytes, + prefixBytes: prefixBytes, + onLine: { line in + Self.parseCodexLine(line, range: range, state: &state) + })) ?? startOffset + + return CodexParseResult( + days: state.days, + parsedBytes: parsedBytes, + lastModel: state.currentModel, + lastTotals: state.previousTotals, + sessionId: state.sessionId) + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index a5ef942b51..8e632b18d2 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -225,141 +225,6 @@ enum CostUsageScanner { return String(describing: identifier) } - static func parseCodexFile( - fileURL: URL, - range: CostUsageDayRange, - startOffset: Int64 = 0, - initialModel: String? = nil, - initialTotals: CostUsageCodexTotals? = nil) -> CodexParseResult - { - var currentModel = initialModel - var previousTotals = initialTotals - var sessionId: String? - - var days: [String: [String: [Int]]] = [:] - - func add(dayKey: String, model: String, input: Int, cached: Int, output: Int) { - guard CostUsageDayRange.isInRange(dayKey: dayKey, since: range.scanSinceKey, until: range.scanUntilKey) - else { return } - let normModel = CostUsagePricing.normalizeCodexModel(model) - - var dayModels = days[dayKey] ?? [:] - var packed = dayModels[normModel] ?? [0, 0, 0] - packed[0] = (packed[safe: 0] ?? 0) + input - packed[1] = (packed[safe: 1] ?? 0) + cached - packed[2] = (packed[safe: 2] ?? 0) + output - dayModels[normModel] = packed - days[dayKey] = dayModels - } - - let maxLineBytes = 256 * 1024 - let prefixBytes = 32 * 1024 - - let parsedBytes = (try? CostUsageJsonl.scan( - fileURL: fileURL, - offset: startOffset, - maxLineBytes: maxLineBytes, - prefixBytes: prefixBytes, - onLine: { line in - guard !line.bytes.isEmpty else { return } - guard !line.wasTruncated else { return } - - guard - line.bytes.containsAscii(#""type":"event_msg""#) - || line.bytes.containsAscii(#""type":"turn_context""#) - || line.bytes.containsAscii(#""type":"session_meta""#) - else { return } - - if line.bytes.containsAscii(#""type":"event_msg""#), !line.bytes.containsAscii(#""token_count""#) { - return - } - - guard - let obj = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any], - let type = obj["type"] as? String - else { return } - - if type == "session_meta" { - if sessionId == nil { - let payload = obj["payload"] as? [String: Any] - sessionId = payload?["session_id"] as? String - ?? payload?["sessionId"] as? String - ?? payload?["id"] as? String - ?? obj["session_id"] as? String - ?? obj["sessionId"] as? String - ?? obj["id"] as? String - } - return - } - - guard let tsText = obj["timestamp"] as? String else { return } - guard let dayKey = Self.dayKeyFromTimestamp(tsText) ?? Self.dayKeyFromParsedISO(tsText) else { return } - - if type == "turn_context" { - if let payload = obj["payload"] as? [String: Any] { - if let model = payload["model"] as? String { - currentModel = model - } else if let info = payload["info"] as? [String: Any], let model = info["model"] as? String { - currentModel = model - } - } - return - } - - guard type == "event_msg" else { return } - guard let payload = obj["payload"] as? [String: Any] else { return } - guard (payload["type"] as? String) == "token_count" else { return } - - let info = payload["info"] as? [String: Any] - let modelFromInfo = info?["model"] as? String - ?? info?["model_name"] as? String - ?? payload["model"] as? String - ?? obj["model"] as? String - let model = modelFromInfo ?? currentModel ?? "gpt-5" - - func toInt(_ v: Any?) -> Int { - if let n = v as? NSNumber { return n.intValue } - return 0 - } - - let total = (info?["total_token_usage"] as? [String: Any]) - let last = (info?["last_token_usage"] as? [String: Any]) - - var deltaInput = 0 - var deltaCached = 0 - var deltaOutput = 0 - - if let total { - let input = toInt(total["input_tokens"]) - let cached = toInt(total["cached_input_tokens"] ?? total["cache_read_input_tokens"]) - let output = toInt(total["output_tokens"]) - - let prev = previousTotals - deltaInput = max(0, input - (prev?.input ?? 0)) - deltaCached = max(0, cached - (prev?.cached ?? 0)) - deltaOutput = max(0, output - (prev?.output ?? 0)) - previousTotals = CostUsageCodexTotals(input: input, cached: cached, output: output) - } else if let last { - deltaInput = max(0, toInt(last["input_tokens"])) - deltaCached = max(0, toInt(last["cached_input_tokens"] ?? last["cache_read_input_tokens"])) - deltaOutput = max(0, toInt(last["output_tokens"])) - } else { - return - } - - if deltaInput == 0, deltaCached == 0, deltaOutput == 0 { return } - let cachedClamp = min(deltaCached, deltaInput) - add(dayKey: dayKey, model: model, input: deltaInput, cached: cachedClamp, output: deltaOutput) - })) ?? startOffset - - return CodexParseResult( - days: days, - parsedBytes: parsedBytes, - lastModel: currentModel, - lastTotals: previousTotals, - sessionId: sessionId) - } - private static func scanCodexFile( fileURL: URL, range: CostUsageDayRange, diff --git a/Tests/CodexBarTests/CLICostTests.swift b/Tests/CodexBarTests/CLICostTests.swift index d376d383ea..4385066389 100644 --- a/Tests/CodexBarTests/CLICostTests.swift +++ b/Tests/CodexBarTests/CLICostTests.swift @@ -86,4 +86,52 @@ struct CLICostTests { #expect(json.contains("\"totalCost\"")) #expect(json.contains("1700000000")) } + + @Test + func encodesExactCodexModelIDsAndZeroCostBreakdowns() throws { + let payload = CostPayload( + provider: "codex", + source: "local", + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + sessionTokens: 155, + sessionCostUSD: 0, + last30DaysTokens: 155, + last30DaysCostUSD: 0, + daily: [ + CostDailyEntryPayload( + date: "2025-12-21", + inputTokens: 120, + outputTokens: 15, + cacheReadTokens: 20, + cacheCreationTokens: nil, + totalTokens: 155, + costUSD: 0, + modelsUsed: ["gpt-5.3-codex-spark", "gpt-5.2-codex"], + modelBreakdowns: [ + CostModelBreakdownPayload(modelName: "gpt-5.3-codex-spark", costUSD: 0), + CostModelBreakdownPayload(modelName: "gpt-5.2-codex", costUSD: 1.23), + ]), + ], + totals: CostTotalsPayload( + totalInputTokens: 120, + totalOutputTokens: 15, + cacheReadTokens: 20, + cacheCreationTokens: nil, + totalTokens: 155, + totalCostUSD: 0), + error: nil) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .secondsSince1970 + let data = try encoder.encode(payload) + guard let json = String(data: data, encoding: .utf8) else { + Issue.record("Failed to decode cost payload JSON") + return + } + + #expect(json.contains("\"gpt-5.3-codex-spark\"")) + #expect(json.contains("\"gpt-5.2-codex\"")) + #expect(!json.contains("\"gpt-5.2\"")) + #expect(json.contains("\"cost\":0")) + } } diff --git a/Tests/CodexBarTests/CostUsageDecodingTests.swift b/Tests/CodexBarTests/CostUsageDecodingTests.swift index 178b9e0706..677d4fb85a 100644 --- a/Tests/CodexBarTests/CostUsageDecodingTests.swift +++ b/Tests/CodexBarTests/CostUsageDecodingTests.swift @@ -119,7 +119,7 @@ struct CostUsageDecodingTests { "totalTokens": 30, "costUSD": 0.12, "models": { - "gpt-5.2": { + "gpt-5.2-codex": { "inputTokens": 10, "outputTokens": 20, "totalTokens": 30, @@ -138,7 +138,7 @@ struct CostUsageDecodingTests { let report = try JSONDecoder().decode(CostUsageDailyReport.self, from: Data(json.utf8)) #expect(report.data.count == 1) #expect(report.data[0].costUSD == 0.12) - #expect(report.data[0].modelsUsed == ["gpt-5.2"]) + #expect(report.data[0].modelsUsed == ["gpt-5.2-codex"]) } @Test @@ -192,7 +192,7 @@ struct CostUsageDecodingTests { "date": "Dec 20, 2025", "totalTokens": 30, "costUSD": 0.12, - "modelsUsed": ["gpt-5.2"], + "modelsUsed": ["gpt-5.2-codex"], "models": { "ignored-model": { "totalTokens": 30 } } @@ -202,7 +202,7 @@ struct CostUsageDecodingTests { """ let report = try JSONDecoder().decode(CostUsageDailyReport.self, from: Data(json.utf8)) - #expect(report.data[0].modelsUsed == ["gpt-5.2"]) + #expect(report.data[0].modelsUsed == ["gpt-5.2-codex"]) } @Test @@ -214,14 +214,14 @@ struct CostUsageDecodingTests { "date": "Dec 20, 2025", "totalTokens": 30, "costUSD": 0.12, - "models": ["gpt-5.2", "gpt-5.2-mini"] + "models": ["gpt-5.2-codex", "gpt-5.2-mini"] } ] } """ let report = try JSONDecoder().decode(CostUsageDailyReport.self, from: Data(json.utf8)) - #expect(report.data[0].modelsUsed == ["gpt-5.2", "gpt-5.2-mini"]) + #expect(report.data[0].modelsUsed == ["gpt-5.2-codex", "gpt-5.2-mini"]) } @Test diff --git a/Tests/CodexBarTests/CostUsageJsonlScannerTests.swift b/Tests/CodexBarTests/CostUsageJsonlScannerTests.swift new file mode 100644 index 0000000000..eee297d298 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageJsonlScannerTests.swift @@ -0,0 +1,69 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite +struct CostUsageJsonlScannerTests { + @Test + func jsonlScannerHandlesLinesAcrossReadChunks() throws { + let root = try self.makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let fileURL = root.appendingPathComponent("large-lines.jsonl", isDirectory: false) + let largeLine = String(repeating: "x", count: 300_000) + let contents = "\(largeLine)\nsmall\n" + try contents.write(to: fileURL, atomically: true, encoding: .utf8) + + var scanned: [(count: Int, truncated: Bool)] = [] + let endOffset = try CostUsageJsonl.scan( + fileURL: fileURL, + maxLineBytes: 400_000, + prefixBytes: 400_000) + { line in + scanned.append((line.bytes.count, line.wasTruncated)) + } + + #expect(endOffset == Int64(Data(contents.utf8).count)) + #expect(scanned.count == 2) + #expect(scanned[0].count == 300_000) + #expect(scanned[0].truncated == false) + #expect(scanned[1].count == 5) + #expect(scanned[1].truncated == false) + } + + @Test + func jsonlScannerMarksPrefixLimitedLinesAsTruncated() throws { + let root = try self.makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let fileURL = root.appendingPathComponent("truncated-lines.jsonl", isDirectory: false) + let shortLine = "ok" + let longLine = String(repeating: "a", count: 2000) + let contents = "\(shortLine)\n\(longLine)\n" + try contents.write(to: fileURL, atomically: true, encoding: .utf8) + + var scanned: [CostUsageJsonl.Line] = [] + _ = try CostUsageJsonl.scan( + fileURL: fileURL, + maxLineBytes: 10000, + prefixBytes: 64) + { line in + scanned.append(line) + } + + #expect(scanned.count == 2) + #expect(String(data: scanned[0].bytes, encoding: .utf8) == "ok") + #expect(scanned[0].wasTruncated == false) + #expect(scanned[1].bytes.count == 64) + #expect(String(data: scanned[1].bytes, encoding: .utf8) == String(repeating: "a", count: 64)) + #expect(scanned[1].wasTruncated == true) + } + + private func makeTemporaryRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "codexbar-cost-usage-jsonl-\(UUID().uuidString)", + isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } +} diff --git a/Tests/CodexBarTests/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift index f70e8d555d..813c45e312 100644 --- a/Tests/CodexBarTests/CostUsagePricingTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingTests.swift @@ -4,11 +4,24 @@ import Testing @Suite struct CostUsagePricingTests { @Test - func normalizesCodexModelVariants() { - #expect(CostUsagePricing.normalizeCodexModel("openai/gpt-5-codex") == "gpt-5") - #expect(CostUsagePricing.normalizeCodexModel("gpt-5.2-codex") == "gpt-5.2") - #expect(CostUsagePricing.normalizeCodexModel("gpt-5.1-codex-max") == "gpt-5.1") + func normalizesCodexModelVariantsExactly() { + #expect(CostUsagePricing.normalizeCodexModel("openai/gpt-5-codex") == "gpt-5-codex") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5-codex-mini") == "gpt-5-codex-mini") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5-2025-10-06") == "gpt-5") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5-chat") == "gpt-5-chat") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5-mini-2025-10-06") == "gpt-5-mini") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5-pro-2025-10-06") == "gpt-5-pro") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.2-codex") == "gpt-5.2-codex") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.2-chat") == "gpt-5.2-chat") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.2-pro") == "gpt-5.2-pro") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.1-codex-max") == "gpt-5.1-codex-max") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.1-codex-mini") == "gpt-5.1-codex-mini") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.4-pro-2026-03-05") == "gpt-5.4-pro") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.3-codex-2026-03-05") == "gpt-5.3-codex") #expect(CostUsagePricing.normalizeCodexModel("gpt-5.3-codex-max") == "gpt-5.3") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.3-chat-latest") == "gpt-5.3-chat-latest") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.4-2025-10-06") == "gpt-5.4") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.3-codex-spark") == "gpt-5.3-codex-spark") } @Test @@ -22,7 +35,22 @@ struct CostUsagePricingTests { } @Test - func codexCostSupportsGpt53CodexMax() { + func codexMiniCostsLessThanBaseModel() throws { + let baseCost = try #require(CostUsagePricing.codexCostUSD( + model: "gpt-5", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5)) + let miniCost = try #require(CostUsagePricing.codexCostUSD( + model: "gpt-5-codex-mini", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5)) + #expect(miniCost < baseCost) + } + + @Test + func codexCostSupportsGpt53CodexMaxFallback() { let cost = CostUsagePricing.codexCostUSD( model: "gpt-5.3-codex-max", inputTokens: 100, @@ -31,6 +59,116 @@ struct CostUsagePricingTests { #expect(cost != nil) } + @Test + func codexCostSupportsGpt54Base() { + let cost = CostUsagePricing.codexCostUSD( + model: "gpt-5.4", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5) + #expect(cost != nil) + } + + @Test + func codexCostSupportsGpt5MiniAndChatAliases() { + #expect(CostUsagePricing.codexCostUSD( + model: "gpt-5-codex-mini", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5) != nil) + #expect(CostUsagePricing.codexCostUSD( + model: "gpt-5-chat", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5) != nil) + #expect(CostUsagePricing.codexCostUSD( + model: "gpt-5.2-chat", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5) != nil) + } + + @Test + func codexCostSupportsSnapshotModels() { + #expect(CostUsagePricing.codexCostUSD( + model: "gpt-5-mini-2025-10-06", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5) != nil) + #expect(CostUsagePricing.codexCostUSD( + model: "gpt-5-pro-2025-10-06", + inputTokens: 100, + cachedInputTokens: 0, + outputTokens: 5) != nil) + #expect(CostUsagePricing.codexCostUSD( + model: "gpt-5.4-2025-10-06", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5) != nil) + } + + @Test + func codexCostSupportsProModelsWithoutCachedReads() { + #expect(CostUsagePricing.codexCostUSD( + model: "gpt-5.2-pro", + inputTokens: 100, + cachedInputTokens: 0, + outputTokens: 5) != nil) + #expect(CostUsagePricing.codexCostUSD( + model: "gpt-5.4-pro", + inputTokens: 100, + cachedInputTokens: 0, + outputTokens: 5) != nil) + } + + @Test + func codexCostUsesStandardGpt54RatesForLocalScans() throws { + let cost = try #require(CostUsagePricing.codexCostUSD( + model: "gpt-5.4", + inputTokens: 300_000, + cachedInputTokens: 10000, + outputTokens: 0)) + let expected = Double(290_000) * 2.5e-6 + Double(10000) * 2.5e-7 + #expect(cost == expected) + } + + @Test + func codexCostUsesStandardGpt54ProRatesForLocalScans() throws { + let cost = try #require(CostUsagePricing.codexCostUSD( + model: "gpt-5.4-pro", + inputTokens: 300_000, + cachedInputTokens: 0, + outputTokens: 100)) + let expected = Double(300_000) * 3e-5 + Double(100) * 1.8e-4 + #expect(cost == expected) + } + + @Test + func codexCostReturnsNilForProModelCachedReads() { + #expect(CostUsagePricing.codexCostUSD( + model: "gpt-5.2-pro", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5) == nil) + #expect(CostUsagePricing.codexCostUSD( + model: "gpt-5.4-pro", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5) == nil) + } + + @Test + func codexCostReturnsZeroForResearchPreviewModel() { + let cost = CostUsagePricing.codexCostUSD( + model: "gpt-5.3-codex-spark", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5) + #expect(cost == 0) + #expect(CostUsagePricing.codexDisplayLabel(model: "gpt-5.3-codex-spark") == "Research Preview") + #expect(CostUsagePricing.codexDisplayLabel(model: "gpt-5.2-codex") == nil) + } + @Test func normalizesClaudeOpus41DatedVariants() { #expect(CostUsagePricing.normalizeClaudeModel("claude-opus-4-1-20250805") == "claude-opus-4-1") diff --git a/Tests/CodexBarTests/CostUsageScannerCodexRecoveryTests.swift b/Tests/CodexBarTests/CostUsageScannerCodexRecoveryTests.swift new file mode 100644 index 0000000000..cc9643fc08 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageScannerCodexRecoveryTests.swift @@ -0,0 +1,310 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite +struct CostUsageScannerCodexRecoveryTests { + @Test + func codexRecoversModelFromTruncatedTurnContextPrefix() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2025, month: 12, day: 29) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + + let model = "openai/gpt-5.2-codex" + let truncatedTurnContext = makeTruncatedCodexPayloadLine( + type: "turn_context", + timestamp: iso0, + payloadBodyPrefix: #""model":"openai/gpt-5.2-codex","notes":"#) + let tokenCount: [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, + ], + ], + ], + ] + + _ = try env.writeCodexSessionFile( + day: day, + filename: "truncated-turn-context.jsonl", + contents: truncatedTurnContext + env.jsonl([tokenCount])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + #expect(report.data.count == 1) + #expect(report.data[0].modelsUsed == [CostUsagePricing.normalizeCodexModel(model)]) + #expect(report.data[0].totalTokens == 110) + } + + @Test + func codexKeepsPriorModelUntilExplicitSwitchAfterTruncatedLines() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2025, month: 12, day: 30) + 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 iso4 = env.isoString(for: day.addingTimeInterval(4)) + let iso5 = env.isoString(for: day.addingTimeInterval(5)) + + let firstModel = "openai/gpt-5.2-codex" + let secondModel = "openai/gpt-5.3-codex" + let initialContext = makeTruncatedCodexPayloadLine( + type: "turn_context", + timestamp: iso0, + payloadBodyPrefix: #""model":"openai/gpt-5.2-codex","notes":"#) + let ambiguousContext = makeTruncatedCodexPayloadLine( + type: "turn_context", + timestamp: iso2, + payloadBodyPrefix: #""notes":"#) + let switchContext: [String: Any] = [ + "type": "turn_context", + "timestamp": iso4, + "payload": [ + "model": secondModel, + ], + ] + + let tokenCount1: [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, + ], + ], + ], + ] + let tokenCount2: [String: Any] = [ + "type": "event_msg", + "timestamp": iso3, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 160, + "cached_input_tokens": 40, + "output_tokens": 16, + ], + ], + ], + ] + let tokenCount3: [String: Any] = [ + "type": "event_msg", + "timestamp": iso5, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 200, + "cached_input_tokens": 50, + "output_tokens": 20, + ], + ], + ], + ] + + let contents = try initialContext + + env.jsonl([tokenCount1]) + + ambiguousContext + + env.jsonl([tokenCount2]) + + env.jsonl([switchContext]) + + env.jsonl([tokenCount3]) + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "mixed-models.jsonl", + contents: contents) + + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) + let parsed = CostUsageScanner.parseCodexFile(fileURL: fileURL, range: range) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let firstPacked = parsed.days[dayKey]?[CostUsagePricing.normalizeCodexModel(firstModel)] ?? [] + let secondPacked = parsed.days[dayKey]?[CostUsagePricing.normalizeCodexModel(secondModel)] ?? [] + + #expect(firstPacked == [160, 40, 16]) + #expect(secondPacked == [40, 10, 4]) + #expect(parsed.lastModel == secondModel) + } + + @Test + func codexDailyReportIgnoresLegacyCacheArtifactAndRecomputesBuckets() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2025, month: 12, day: 31) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let model = "openai/gpt-5.2-codex" + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + + let legacyCacheURL = env.cacheRoot + .appendingPathComponent("cost-usage", isDirectory: true) + .appendingPathComponent("codex-v1.json", isDirectory: false) + try FileManager.default.createDirectory( + at: legacyCacheURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + let staleCache = CostUsageCache( + version: 1, + lastScanUnixMs: Int64(day.timeIntervalSince1970 * 1000), + files: [ + "/tmp/stale-session.jsonl": CostUsageScanner.makeFileUsage( + mtimeUnixMs: 1, + size: 1, + days: [dayKey: ["gpt-5": [999, 0, 1]]], + parsedBytes: 1, + lastModel: "gpt-5", + lastTotals: CostUsageCodexTotals(input: 999, cached: 0, output: 1), + sessionId: "stale-session"), + ], + days: [dayKey: ["gpt-5": [999, 0, 1]]], + roots: nil) + let staleData = try JSONEncoder().encode(staleCache) + try staleData.write(to: legacyCacheURL, options: [.atomic]) + + let truncatedTurnContext = makeTruncatedCodexPayloadLine( + type: "turn_context", + timestamp: iso0, + payloadBodyPrefix: #""model":"openai/gpt-5.2-codex","notes":"#) + let tokenCount: [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, + ], + ], + ], + ] + _ = try env.writeCodexSessionFile( + day: day, + filename: "cache-rollout.jsonl", + contents: truncatedTurnContext + env.jsonl([tokenCount])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 3600 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + let currentCacheURL = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: env.cacheRoot) + #expect(currentCacheURL != legacyCacheURL) + #expect(report.data.count == 1) + #expect(report.data[0].modelsUsed == [CostUsagePricing.normalizeCodexModel(model)]) + #expect(report.data[0].totalTokens == 110) + } + + @Test + func codexRecoversModelWhenTruncatedPrefixEndsMidUTF8Sequence() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 1, day: 1) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let model = "openai/gpt-5.2-codex" + + let truncatedTurnContext = makeUTF8BoundaryTruncatedTurnContextLine( + timestamp: iso0, + model: model) + let tokenCount: [String: Any] = [ + "type": "event_msg", + "timestamp": iso1, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 120, + "cached_input_tokens": 24, + "output_tokens": 12, + ], + ], + ], + ] + + _ = try env.writeCodexSessionFile( + day: day, + filename: "utf8-boundary.jsonl", + contents: truncatedTurnContext + env.jsonl([tokenCount])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + #expect(report.data.count == 1) + #expect(report.data[0].modelsUsed == [CostUsagePricing.normalizeCodexModel(model)]) + #expect(report.data[0].totalTokens == 132) + } +} + +private func makeTruncatedCodexPayloadLine( + type: String, + timestamp: String? = nil, + payloadBodyPrefix: String, + fillerCount: Int = 40000) -> String +{ + let filler = String(repeating: "a", count: fillerCount) + var fields = [#""type":"\#(type)""#] + if let timestamp { + fields.append(#""timestamp":"\#(timestamp)""#) + } + let payload = #""payload":{"# + payloadBodyPrefix + filler + #""}"# + fields.append(payload) + return "{\(fields.joined(separator: ","))}\n" +} + +private func makeUTF8BoundaryTruncatedTurnContextLine(timestamp: String, model: String) -> String { + let prefixStart = #"{"type":"turn_context","timestamp":"\#(timestamp)","payload":{"model":"\#(model)","notes":""# + let prefixByteLimit = 32 * 1024 + let prefixBytes = prefixStart.lengthOfBytes(using: .utf8) + let paddingCount = (0...3).first { candidate in + (prefixByteLimit - prefixBytes - candidate) % 4 != 0 + } ?? 1 + let filler = String(repeating: "a", count: paddingCount) + String(repeating: "๐Ÿ™‚", count: 10000) + return prefixStart + filler + #""}}"# + "\n" +} diff --git a/Tests/CodexBarTests/CostUsageScannerTests.swift b/Tests/CodexBarTests/CostUsageScannerTests.swift index ecad8208d7..b1107a6c9e 100644 --- a/Tests/CodexBarTests/CostUsageScannerTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerTests.swift @@ -56,7 +56,10 @@ struct CostUsageScannerTests { now: day, options: options) #expect(first.data.count == 1) - #expect(first.data[0].modelsUsed == ["gpt-5.2"]) + #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), + ]) #expect(first.data[0].totalTokens == 110) #expect((first.data[0].costUSD ?? 0) > 0) @@ -85,6 +88,7 @@ struct CostUsageScannerTests { 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)) } @@ -476,6 +480,7 @@ struct CostUsageScannerTests { let model = "openai/gpt-5.2-codex" let normalized = CostUsagePricing.normalizeCodexModel(model) + #expect(normalized == "gpt-5.2-codex") let turnContext: [String: Any] = [ "type": "turn_context", "timestamp": iso0, @@ -838,63 +843,9 @@ struct CostUsageScannerTests { #expect(report.data[0].outputTokens == 15) #expect(report.data[0].totalTokens == 45) } - - @Test - func jsonlScannerHandlesLinesAcrossReadChunks() throws { - let env = try CostUsageTestEnvironment() - defer { env.cleanup() } - - let fileURL = env.root.appendingPathComponent("large-lines.jsonl", isDirectory: false) - let largeLine = String(repeating: "x", count: 300_000) - let contents = "\(largeLine)\nsmall\n" - try contents.write(to: fileURL, atomically: true, encoding: .utf8) - - var scanned: [(count: Int, truncated: Bool)] = [] - let endOffset = try CostUsageJsonl.scan( - fileURL: fileURL, - maxLineBytes: 400_000, - prefixBytes: 400_000) - { line in - scanned.append((line.bytes.count, line.wasTruncated)) - } - - #expect(endOffset == Int64(Data(contents.utf8).count)) - #expect(scanned.count == 2) - #expect(scanned[0].count == 300_000) - #expect(scanned[0].truncated == false) - #expect(scanned[1].count == 5) - #expect(scanned[1].truncated == false) - } - - @Test - func jsonlScannerMarksPrefixLimitedLinesAsTruncated() throws { - let env = try CostUsageTestEnvironment() - defer { env.cleanup() } - - let fileURL = env.root.appendingPathComponent("truncated-lines.jsonl", isDirectory: false) - let shortLine = "ok" - let longLine = String(repeating: "a", count: 2000) - let contents = "\(shortLine)\n\(longLine)\n" - try contents.write(to: fileURL, atomically: true, encoding: .utf8) - - var scanned: [CostUsageJsonl.Line] = [] - _ = try CostUsageJsonl.scan( - fileURL: fileURL, - maxLineBytes: 10000, - prefixBytes: 64) - { line in - scanned.append(line) - } - - #expect(scanned.count == 2) - #expect(String(data: scanned[0].bytes, encoding: .utf8) == "ok") - #expect(scanned[0].wasTruncated == false) - #expect(scanned[1].bytes.isEmpty) - #expect(scanned[1].wasTruncated == true) - } } -private struct CostUsageTestEnvironment { +struct CostUsageTestEnvironment { let root: URL let cacheRoot: URL let codexHomeRoot: URL diff --git a/Tests/CodexBarTests/UsageFormatterTests.swift b/Tests/CodexBarTests/UsageFormatterTests.swift index 072b299df6..a14c9fe3b3 100644 --- a/Tests/CodexBarTests/UsageFormatterTests.swift +++ b/Tests/CodexBarTests/UsageFormatterTests.swift @@ -99,6 +99,13 @@ struct UsageFormatterTests { #expect(UsageFormatter.modelDisplayName("gpt-4o-2024-08-06") == "gpt-4o") #expect(UsageFormatter.modelDisplayName("Claude Opus 4.5 2025 1101") == "Claude Opus 4.5") #expect(UsageFormatter.modelDisplayName("claude-sonnet-4-5") == "claude-sonnet-4-5") + #expect(UsageFormatter.modelDisplayName("gpt-5.3-codex-spark") == "gpt-5.3-codex-spark") + } + + @Test + func modelCostDetailUsesResearchPreviewLabel() { + #expect(UsageFormatter.modelCostDetail("gpt-5.3-codex-spark", costUSD: 0) == "Research Preview") + #expect(UsageFormatter.modelCostDetail("gpt-5.2-codex", costUSD: 0.42) == "$0.42") } @Test