diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index 9c77cf4ef..5852d05ac 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 226d43569..b03b18500 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/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index ffc7b1b91..8e7b06092 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/Tests/CodexBarTests/CLICostTests.swift b/Tests/CodexBarTests/CLICostTests.swift index d376d383e..438506638 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 178b9e070..677d4fb85 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 000000000..eee297d29 --- /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 f70e8d555..813c45e31 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/CostUsageScannerTests.swift b/Tests/CodexBarTests/CostUsageScannerTests.swift index b38a0d31a..b1107a6c9 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,61 +843,6 @@ 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.count == 64) - #expect(String(data: scanned[1].bytes, encoding: .utf8) == String(repeating: "a", count: 64)) - #expect(scanned[1].wasTruncated == true) - } } struct CostUsageTestEnvironment { diff --git a/Tests/CodexBarTests/UsageFormatterTests.swift b/Tests/CodexBarTests/UsageFormatterTests.swift index 072b299df..a14c9fe3b 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