From 7116caae8d1196975d9344f1f219cedb1a68c702 Mon Sep 17 00:00:00 2001 From: Bryan Font Date: Thu, 12 Mar 2026 14:18:45 -0400 Subject: [PATCH 1/9] Preserve exact GPT-5 Codex pricing keys --- .../CodexBar/CostHistoryChartMenuView.swift | 12 ++- Sources/CodexBarCore/UsageFormatter.swift | 9 ++ .../Vendored/CostUsage/CostUsagePricing.swift | 96 ++++++++++++++++--- Tests/CodexBarTests/CLICostTests.swift | 48 ++++++++++ .../CostUsageDecodingTests.swift | 12 +-- .../CostUsageJsonlScannerTests.swift | 68 +++++++++++++ .../CodexBarTests/CostUsagePricingTests.swift | 28 ++++-- .../CodexBarTests/CostUsageScannerTests.swift | 62 ++---------- Tests/CodexBarTests/UsageFormatterTests.swift | 7 ++ 9 files changed, 253 insertions(+), 89 deletions(-) create mode 100644 Tests/CodexBarTests/CostUsageJsonlScannerTests.swift 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..36ad430e8 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -4,7 +4,8 @@ enum CostUsagePricing { struct CodexPricing: Sendable { let inputCostPerToken: Double let outputCostPerToken: Double - let cacheReadInputCostPerToken: Double + let cacheReadInputCostPerToken: Double? + let displayLabel: String? } struct ClaudePricing: Sendable { @@ -24,31 +25,83 @@ enum CostUsagePricing { "gpt-5": CodexPricing( inputCostPerToken: 1.25e-6, outputCostPerToken: 1e-5, - cacheReadInputCostPerToken: 1.25e-7), + 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-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-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-codex": CodexPricing( inputCostPerToken: 1.75e-6, outputCostPerToken: 1.4e-5, - cacheReadInputCostPerToken: 1.75e-7), - "gpt-5.3": 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), + 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] = [ @@ -169,13 +222,25 @@ enum CostUsagePricing { if trimmed.hasPrefix("openai/") { trimmed = String(trimmed.dropFirst("openai/".count)) } - if let codexRange = trimmed.range(of: "-codex") { - let base = String(trimmed[.. 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,8 +275,9 @@ 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) + let cachedRate = pricing.cacheReadInputCostPerToken ?? pricing.inputCostPerToken return Double(nonCached) * pricing.inputCostPerToken - + Double(cached) * pricing.cacheReadInputCostPerToken + + Double(cached) * cachedRate + Double(max(0, outputTokens)) * pricing.outputCostPerToken } 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..cdf5c725a --- /dev/null +++ b/Tests/CodexBarTests/CostUsageJsonlScannerTests.swift @@ -0,0 +1,68 @@ +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.isEmpty) + #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..662b72c78 100644 --- a/Tests/CodexBarTests/CostUsagePricingTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingTests.swift @@ -4,11 +4,13 @@ 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") - #expect(CostUsagePricing.normalizeCodexModel("gpt-5.3-codex-max") == "gpt-5.3") + func normalizesCodexModelVariantsExactly() { + #expect(CostUsagePricing.normalizeCodexModel("openai/gpt-5-codex") == "gpt-5-codex") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.2-codex") == "gpt-5.2-codex") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.1-codex-max") == "gpt-5.1-codex-max") + #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-spark") == "gpt-5.3-codex-spark") } @Test @@ -22,15 +24,27 @@ struct CostUsagePricingTests { } @Test - func codexCostSupportsGpt53CodexMax() { + func codexCostSupportsGpt53Codex() { let cost = CostUsagePricing.codexCostUSD( - model: "gpt-5.3-codex-max", + model: "gpt-5.3-codex", inputTokens: 100, cachedInputTokens: 10, outputTokens: 5) #expect(cost != 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 From 85a7a308071b92d173299955afa3af47e48bd117 Mon Sep 17 00:00:00 2001 From: Bryan Font Date: Fri, 13 Mar 2026 09:18:34 -0400 Subject: [PATCH 2/9] Align JSONL scanner test expectations --- Tests/CodexBarTests/CostUsageJsonlScannerTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Tests/CodexBarTests/CostUsageJsonlScannerTests.swift b/Tests/CodexBarTests/CostUsageJsonlScannerTests.swift index cdf5c725a..eee297d29 100644 --- a/Tests/CodexBarTests/CostUsageJsonlScannerTests.swift +++ b/Tests/CodexBarTests/CostUsageJsonlScannerTests.swift @@ -54,7 +54,8 @@ struct CostUsageJsonlScannerTests { #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].bytes.count == 64) + #expect(String(data: scanned[1].bytes, encoding: .utf8) == String(repeating: "a", count: 64)) #expect(scanned[1].wasTruncated == true) } From e1331c30f2f07ab4dec82d40648613b70819afda Mon Sep 17 00:00:00 2001 From: Bryan Font Date: Fri, 13 Mar 2026 09:35:16 -0400 Subject: [PATCH 3/9] Restore GPT-5 pricing normalization rules --- .../Vendored/CostUsage/CostUsagePricing.swift | 128 ++++++++++++++++-- .../CodexBarTests/CostUsagePricingTests.swift | 128 +++++++++++++++++- 2 files changed, 242 insertions(+), 14 deletions(-) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 36ad430e8..8e7b06092 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -5,7 +5,31 @@ enum CostUsagePricing { let inputCostPerToken: Double let outputCostPerToken: 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 { @@ -27,11 +51,26 @@ enum CostUsagePricing { outputCostPerToken: 1e-5, 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, 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, @@ -52,6 +91,11 @@ enum CostUsagePricing { outputCostPerToken: 1e-5, 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, @@ -72,6 +116,16 @@ enum CostUsagePricing { outputCostPerToken: 1.4e-5, 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, @@ -87,6 +141,16 @@ enum CostUsagePricing { 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, + displayLabel: nil), + "gpt-5.3-chat-latest": CodexPricing( + inputCostPerToken: 1.75e-6, + outputCostPerToken: 1.4e-5, + cacheReadInputCostPerToken: 1.75e-7, + displayLabel: nil), "gpt-5.3-codex-spark": CodexPricing( inputCostPerToken: 0, outputCostPerToken: 0, @@ -218,22 +282,44 @@ enum CostUsagePricing { ] static func normalizeCodexModel(_ raw: String) -> String { + 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"), !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 + } - if self.codex[trimmed] != nil { - return trimmed - } + private static func codexSnapshotBaseModel(_ raw: String) -> String? { + let patterns = [ + #"-\d{4}-\d{2}-\d{2}$"#, + #"-\d{8}$"#, + ] - if let datedSuffix = trimmed.range(of: #"-\d{4}-\d{2}-\d{2}$"#, options: .regularExpression) { - let base = String(trimmed[.. String? { @@ -275,10 +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) - let cachedRate = pricing.cacheReadInputCostPerToken ?? pricing.inputCostPerToken - return Double(nonCached) * pricing.inputCostPerToken - + Double(cached) * cachedRate - + 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/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift index 662b72c78..813c45e31 100644 --- a/Tests/CodexBarTests/CostUsagePricingTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingTests.swift @@ -6,10 +6,21 @@ struct CostUsagePricingTests { @Test 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") } @@ -24,15 +35,128 @@ struct CostUsagePricingTests { } @Test - func codexCostSupportsGpt53Codex() { + 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", + model: "gpt-5.3-codex-max", inputTokens: 100, cachedInputTokens: 10, outputTokens: 5) #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( From 67d7ecb99560ba53245573ae7eecf99fef3b5b44 Mon Sep 17 00:00:00 2001 From: Bryan Font Date: Thu, 12 Mar 2026 16:36:34 -0400 Subject: [PATCH 4/9] Generalize provider usage bucket groups --- Sources/CodexBar/MenuCardView.swift | 186 ++++++++++++++++-- Sources/CodexBar/MenuDescriptor.swift | 29 +++ .../CodexBar/StatusItemController+Menu.swift | 62 ++++-- Sources/CodexBarCLI/CLIRenderer.swift | 19 ++ .../CodexOAuth/CodexOAuthUsageFetcher.swift | 14 ++ .../Codex/CodexProviderDescriptor.swift | 32 +++ Sources/CodexBarCore/UsageFetcher.swift | 44 +++++ Tests/CodexBarTests/CLISnapshotTests.swift | 103 ++++++++++ Tests/CodexBarTests/CodexOAuthTests.swift | 94 +++++++++ .../MenuCardModelSparkTests.swift | 123 ++++++++++++ .../MenuDescriptorSparkTests.swift | 144 ++++++++++++++ Tests/CodexBarTests/StatusMenuTests.swift | 119 +++++++++++ 12 files changed, 936 insertions(+), 33 deletions(-) create mode 100644 Tests/CodexBarTests/MenuCardModelSparkTests.swift create mode 100644 Tests/CodexBarTests/MenuDescriptorSparkTests.swift diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 789a517e1..5702a2f73 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -2,6 +2,14 @@ import AppKit import CodexBarCore import SwiftUI +extension View { + fileprivate func menuCardSectionTitleStyle() -> some View { + self + .font(.body) + .fontWeight(.medium) + } +} + /// SwiftUI card used inside the NSMenu to mirror Apple's rich menu panels. struct UsageMenuCardView: View { struct Model { @@ -27,6 +35,8 @@ struct UsageMenuCardView: View { struct Metric: Identifiable { let id: String let title: String + let groupID: String? + let groupTitle: String? let percent: Double let percentStyle: PercentStyle let resetText: String? @@ -41,6 +51,12 @@ struct UsageMenuCardView: View { } } + struct MetricGroup: Identifiable { + let id: String + let title: String? + let metrics: [Metric] + } + enum SubtitleStyle { case info case loading @@ -90,6 +106,47 @@ struct UsageMenuCardView: View { return metric.title } + static func metricGroups(metrics: [Model.Metric]) -> [Model.MetricGroup] { + guard !metrics.isEmpty else { return [] } + var groups: [Model.MetricGroup] = [] + let primaryMetrics = metrics.filter { $0.groupID == nil } + if !primaryMetrics.isEmpty { + groups.append(.init(id: "primary", title: nil, metrics: primaryMetrics)) + } + + var supplementalGroups: [String: [Model.Metric]] = [:] + var supplementalOrder: [String] = [] + for metric in metrics { + guard let groupID = metric.groupID else { continue } + if supplementalGroups[groupID] == nil { + supplementalOrder.append(groupID) + } + supplementalGroups[groupID, default: []].append(metric) + } + + for groupID in supplementalOrder { + guard let metrics = supplementalGroups[groupID], !metrics.isEmpty else { continue } + groups.append(.init(id: groupID, title: metrics.first?.groupTitle, metrics: metrics)) + } + + return groups + } + + static func primaryMetricGroup(metrics: [Model.Metric]) -> Model.MetricGroup? { + self.metricGroups(metrics: metrics).first { $0.id == "primary" } + } + + static func supplementalMetricGroups(metrics: [Model.Metric]) -> [Model.MetricGroup] { + self.metricGroups(metrics: metrics).filter { $0.id != "primary" } + } + + static func displayMetricTitle(provider: UsageProvider, metric: Model.Metric) -> String { + if provider == .openrouter, metric.id == "primary" { + return "API key limit" + } + return metric.title + } + var body: some View { VStack(alignment: .leading, spacing: 6) { UsageMenuCardHeaderView(model: self.model) @@ -111,15 +168,29 @@ struct UsageMenuCardView: View { let hasCredits = self.model.creditsText != nil let hasProviderCost = self.model.providerCost != nil let hasCost = self.model.tokenUsage != nil || hasProviderCost + let metricGroups = Self.metricGroups(metrics: self.model.metrics) VStack(alignment: .leading, spacing: 12) { if hasUsage { VStack(alignment: .leading, spacing: 12) { - ForEach(self.model.metrics, id: \.id) { metric in - MetricRow( - metric: metric, - title: Self.popupMetricTitle(provider: self.model.provider, metric: metric), - progressColor: self.model.progressColor) + ForEach(Array(metricGroups.enumerated()), id: \.element.id) { index, group in + if index > 0 { + Divider() + } + VStack(alignment: .leading, spacing: 12) { + if let title = group.title { + Text(title) + .menuCardSectionTitleStyle() + } + ForEach(group.metrics, id: \.id) { metric in + MetricRow( + metric: metric, + title: Self.displayMetricTitle( + provider: self.model.provider, + metric: metric), + progressColor: self.model.progressColor) + } + } } if !self.model.usageNotes.isEmpty { UsageNotesContent(notes: self.model.usageNotes) @@ -301,8 +372,7 @@ private struct ProviderCostContent: View { var body: some View { VStack(alignment: .leading, spacing: 6) { Text(self.section.title) - .font(.body) - .fontWeight(.medium) + .menuCardSectionTitleStyle() UsageProgressBar( percent: self.section.percentUsed, tint: self.progressColor, @@ -328,8 +398,7 @@ private struct MetricRow: View { var body: some View { VStack(alignment: .leading, spacing: 6) { Text(self.title) - .font(.body) - .fontWeight(.medium) + .menuCardSectionTitleStyle() UsageProgressBar( percent: self.metric.percent, tint: self.progressColor, @@ -425,6 +494,7 @@ struct UsageMenuCardUsageSectionView: View { @Environment(\.menuItemHighlighted) private var isHighlighted var body: some View { + let metricGroups = UsageMenuCardView.metricGroups(metrics: self.model.metrics) VStack(alignment: .leading, spacing: 12) { if self.model.metrics.isEmpty { if !self.model.usageNotes.isEmpty { @@ -435,11 +505,24 @@ struct UsageMenuCardUsageSectionView: View { .font(.subheadline) } } else { - ForEach(self.model.metrics, id: \.id) { metric in - MetricRow( - metric: metric, - title: UsageMenuCardView.popupMetricTitle(provider: self.model.provider, metric: metric), - progressColor: self.model.progressColor) + ForEach(Array(metricGroups.enumerated()), id: \.element.id) { index, group in + if index > 0 { + Divider() + } + VStack(alignment: .leading, spacing: 12) { + if let title = group.title { + Text(title) + .menuCardSectionTitleStyle() + } + ForEach(group.metrics, id: \.id) { metric in + MetricRow( + metric: metric, + title: UsageMenuCardView.displayMetricTitle( + provider: self.model.provider, + metric: metric), + progressColor: self.model.progressColor) + } + } } if !self.model.usageNotes.isEmpty { UsageNotesContent(notes: self.model.usageNotes) @@ -456,6 +539,52 @@ struct UsageMenuCardUsageSectionView: View { } } +struct UsageMenuCardMetricGroupSectionView: View { + let provider: UsageProvider + let group: UsageMenuCardView.Model.MetricGroup + let usageNotes: [String] + let placeholder: String? + let topPadding: CGFloat + let bottomPadding: CGFloat + let width: CGFloat + let progressColor: Color + @Environment(\.menuItemHighlighted) private var isHighlighted + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + if let title = self.group.title { + Text(title) + .menuCardSectionTitleStyle() + } + if self.group.metrics.isEmpty { + if !self.usageNotes.isEmpty { + UsageNotesContent(notes: self.usageNotes) + } else if let placeholder = self.placeholder { + Text(placeholder) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .font(.subheadline) + } + } else { + ForEach(self.group.metrics, id: \.id) { metric in + MetricRow( + metric: metric, + title: UsageMenuCardView.displayMetricTitle( + provider: self.provider, + metric: metric), + progressColor: self.progressColor) + } + if !self.usageNotes.isEmpty { + UsageNotesContent(notes: self.usageNotes) + } + } + } + .padding(.horizontal, 16) + .padding(.top, self.topPadding) + .padding(.bottom, self.bottomPadding) + .frame(width: self.width, alignment: .leading) + } +} + struct UsageMenuCardCreditsSectionView: View { let model: UsageMenuCardView.Model let showBottomDivider: Bool @@ -508,8 +637,7 @@ private struct CreditsBarContent: View { var body: some View { VStack(alignment: .leading, spacing: 6) { Text("Credits") - .font(.body) - .fontWeight(.medium) + .menuCardSectionTitleStyle() if let percentLeft { UsageProgressBar( percent: percentLeft, @@ -924,6 +1052,8 @@ extension UsageMenuCardView.Model { metrics.append(Metric( id: "primary", title: input.metadata.sessionLabel, + groupID: nil, + groupTitle: nil, percent: Self.clamped( input.usageBarsShowUsed ? primary.usedPercent : primary.remainingPercent), percentStyle: percentStyle, @@ -961,6 +1091,8 @@ extension UsageMenuCardView.Model { metrics.append(Metric( id: "secondary", title: input.metadata.weeklyLabel, + groupID: nil, + groupTitle: nil, percent: Self.clamped(input.usageBarsShowUsed ? weekly.usedPercent : weekly.remainingPercent), percentStyle: percentStyle, resetText: weeklyResetText, @@ -986,6 +1118,8 @@ extension UsageMenuCardView.Model { metrics.append(Metric( id: "tertiary", title: input.metadata.opusLabel ?? "Sonnet", + groupID: nil, + groupTitle: nil, percent: Self.clamped(input.usageBarsShowUsed ? opus.usedPercent : opus.remainingPercent), percentStyle: percentStyle, resetText: Self.resetText(for: opus, style: input.resetTimeDisplayStyle, now: input.now), @@ -995,12 +1129,32 @@ extension UsageMenuCardView.Model { pacePercent: nil, paceOnTop: true)) } + for bucketGroup in snapshot.usageBucketGroups { + for bucket in bucketGroup.buckets { + metrics.append(Metric( + id: bucket.id, + title: bucket.title, + groupID: bucketGroup.id, + groupTitle: bucketGroup.title, + percent: Self.clamped( + input.usageBarsShowUsed ? bucket.window.usedPercent : bucket.window.remainingPercent), + percentStyle: percentStyle, + resetText: Self.resetText(for: bucket.window, style: input.resetTimeDisplayStyle, now: input.now), + detailText: nil, + detailLeftText: nil, + detailRightText: nil, + pacePercent: nil, + paceOnTop: true)) + } + } if input.provider == .codex, let remaining = input.dashboard?.codeReviewRemainingPercent { let percent = input.usageBarsShowUsed ? (100 - remaining) : remaining metrics.append(Metric( id: "code-review", title: "Code review", + groupID: nil, + groupTitle: nil, percent: Self.clamped(percent), percentStyle: percentStyle, resetText: nil, diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 05aa55fff..d7e9c8fcc 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -179,6 +179,11 @@ struct MenuDescriptor { resetStyle: resetStyle, showUsed: settings.usageBarsShowUsed) } + Self.appendUsageBucketGroupEntries( + entries: &entries, + snapshot: snap, + resetStyle: resetStyle, + showUsed: settings.usageBarsShowUsed) if let cost = snap.providerCost { if cost.currencyCode == "Quota" { @@ -407,6 +412,30 @@ struct MenuDescriptor { return false } + private static func appendUsageBucketGroupEntries( + entries: inout [Entry], + snapshot: UsageSnapshot, + resetStyle: ResetTimeDisplayStyle, + showUsed: Bool) + { + for group in snapshot.usageBucketGroups { + guard !group.buckets.isEmpty else { continue } + if entries.count > 1 { + entries.append(.divider) + } + entries.append(.text(group.title, .headline)) + + for bucket in group.buckets { + self.appendRateWindow( + entries: &entries, + title: bucket.title, + window: bucket.window, + resetStyle: resetStyle, + showUsed: showUsed) + } + } + } + private static func appendRateWindow( entries: inout [Entry], title: String, diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 5d9173624..78479c060 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -439,7 +439,9 @@ extension StatusItemController { } guard let model = self.menuCardModel(for: context.selectedProvider) else { return false } - if context.openAIContext.hasOpenAIWebMenuItems { + let supplementalMetricGroups = UsageMenuCardView.supplementalMetricGroups(metrics: model.metrics) + let shouldRenderSectionedCard = context.openAIContext.hasOpenAIWebMenuItems || !supplementalMetricGroups.isEmpty + if shouldRenderSectionedCard { let webItems = OpenAIWebMenuItems( hasUsageBreakdown: context.openAIContext.hasUsageBreakdown, hasCreditsHistory: context.openAIContext.hasCreditsHistory, @@ -450,7 +452,10 @@ extension StatusItemController { provider: context.currentProvider, width: context.menuWidth, webItems: webItems) - return true + if !context.openAIContext.hasOpenAIWebMenuItems { + menu.addItem(.separator()) + } + return context.openAIContext.hasOpenAIWebMenuItems } menu.addItem(self.makeMenuCardItem( @@ -841,7 +846,10 @@ extension StatusItemController { width: CGFloat, webItems: OpenAIWebMenuItems) { - let hasUsageBlock = !model.metrics.isEmpty || model.placeholder != nil + let primaryMetricGroup = UsageMenuCardView.primaryMetricGroup(metrics: model.metrics) + let supplementalMetricGroups = UsageMenuCardView.supplementalMetricGroups(metrics: model.metrics) + let hasUsageBlock = primaryMetricGroup != nil || !model.usageNotes.isEmpty || model.placeholder != nil + let hasSupplementalUsageBlocks = !supplementalMetricGroups.isEmpty let hasCredits = model.creditsText != nil let hasExtraUsage = model.providerCost != nil let hasCost = model.tokenUsage != nil @@ -852,16 +860,20 @@ extension StatusItemController { let headerView = UsageMenuCardHeaderSectionView( model: model, - showDivider: hasUsageBlock, + showDivider: hasUsageBlock || hasSupplementalUsageBlocks, width: width) menu.addItem(self.makeMenuCardItem(headerView, id: "menuCardHeader", width: width)) if hasUsageBlock { - let usageView = UsageMenuCardUsageSectionView( - model: model, - showBottomDivider: false, + let usageView = UsageMenuCardMetricGroupSectionView( + provider: provider, + group: primaryMetricGroup ?? .init(id: "primary", title: nil, metrics: []), + usageNotes: model.usageNotes, + placeholder: model.placeholder, + topPadding: 10, bottomPadding: usageBottomPadding, - width: width) + width: width, + progressColor: model.progressColor) let usageSubmenu = self.makeUsageSubmenu( provider: provider, snapshot: self.store.snapshot(for: provider), @@ -873,14 +885,30 @@ extension StatusItemController { submenu: usageSubmenu)) } + for (index, supplementalMetricGroup) in supplementalMetricGroups.enumerated() { + if hasUsageBlock || index > 0 { + menu.addItem(.separator()) + } + let supplementalView = UsageMenuCardMetricGroupSectionView( + provider: provider, + group: supplementalMetricGroup, + usageNotes: [], + placeholder: nil, + topPadding: sectionSpacing, + bottomPadding: bottomPadding, + width: width, + progressColor: model.progressColor) + menu.addItem(self.makeMenuCardItem( + supplementalView, + id: "menuCardUsageGroup-\(supplementalMetricGroup.id)", + width: width)) + } + if hasCredits || hasExtraUsage || hasCost { menu.addItem(.separator()) } if hasCredits { - if hasExtraUsage || hasCost { - menu.addItem(.separator()) - } let creditsView = UsageMenuCardCreditsSectionView( model: model, showBottomDivider: false, @@ -897,10 +925,10 @@ extension StatusItemController { menu.addItem(self.makeBuyCreditsItem()) } } + if hasCredits, hasExtraUsage || hasCost { + menu.addItem(.separator()) + } if hasExtraUsage { - if hasCredits { - menu.addItem(.separator()) - } let extraUsageView = UsageMenuCardExtraUsageSectionView( model: model, topPadding: sectionSpacing, @@ -911,10 +939,10 @@ extension StatusItemController { id: "menuCardExtraUsage", width: width)) } + if hasExtraUsage, hasCost { + menu.addItem(.separator()) + } if hasCost { - if hasCredits || hasExtraUsage { - menu.addItem(.separator()) - } let costView = UsageMenuCardCostSectionView( model: model, topPadding: sectionSpacing, diff --git a/Sources/CodexBarCLI/CLIRenderer.swift b/Sources/CodexBarCLI/CLIRenderer.swift index 357c815bc..c43898da4 100644 --- a/Sources/CodexBarCLI/CLIRenderer.swift +++ b/Sources/CodexBarCLI/CLIRenderer.swift @@ -33,6 +33,7 @@ enum CLIRenderer { now: now, lines: &lines) self.appendTertiaryLines(snapshot: snapshot, metadata: meta, context: context, now: now, lines: &lines) + self.appendUsageBucketGroupLines(snapshot: snapshot, context: context, now: now, lines: &lines) self.appendCreditsLine(provider: provider, credits: credits, useColor: context.useColor, lines: &lines) self.appendIdentityAndNotes( provider: provider, @@ -120,6 +121,24 @@ enum CLIRenderer { } } + private static func appendUsageBucketGroupLines( + snapshot: UsageSnapshot, + context: RenderContext, + now: Date, + lines: inout [String]) + { + for group in snapshot.usageBucketGroups { + guard !group.buckets.isEmpty else { continue } + lines.append(group.title) + for bucket in group.buckets { + lines.append(self.rateLine(title: bucket.title, window: bucket.window, useColor: context.useColor)) + if let reset = self.resetLine(for: bucket.window, style: context.resetStyle, now: now) { + lines.append(self.subtleLine(reset, useColor: context.useColor)) + } + } + } + } + private static func appendCreditsLine( provider: UsageProvider, credits: CreditsSnapshot?, diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift index 174c08c54..e810a048f 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift @@ -6,11 +6,13 @@ import FoundationNetworking public struct CodexUsageResponse: Decodable, Sendable { public let planType: PlanType? public let rateLimit: RateLimitDetails? + public let additionalRateLimits: [AdditionalRateLimit]? public let credits: CreditDetails? enum CodingKeys: String, CodingKey { case planType = "plan_type" case rateLimit = "rate_limit" + case additionalRateLimits = "additional_rate_limits" case credits } @@ -94,6 +96,18 @@ public struct CodexUsageResponse: Decodable, Sendable { } } + public struct AdditionalRateLimit: Decodable, Sendable { + public let limitName: String? + public let meteredFeature: String? + public let rateLimit: RateLimitDetails? + + enum CodingKeys: String, CodingKey { + case limitName = "limit_name" + case meteredFeature = "metered_feature" + case rateLimit = "rate_limit" + } + } + public struct CreditDetails: Decodable, Sendable { public let hasCredits: Bool public let unlimited: Bool diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift index 12b68cd22..0cf40c138 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift @@ -164,6 +164,7 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { private static func mapUsage(_ response: CodexUsageResponse, credentials: CodexOAuthCredentials) -> UsageSnapshot { let primary = Self.makeWindow(response.rateLimit?.primaryWindow) let secondary = Self.makeWindow(response.rateLimit?.secondaryWindow) + let usageBucketGroups = Self.makeUsageBucketGroups(response.additionalRateLimits) let identity = ProviderIdentitySnapshot( providerID: .codex, @@ -175,6 +176,7 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { primary: primary ?? RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil), secondary: secondary, tertiary: nil, + usageBucketGroups: usageBucketGroups, updatedAt: Date(), identity: identity) } @@ -195,6 +197,36 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { resetDescription: resetDescription) } + private static func makeUsageBucketGroups( + _ rateLimits: [CodexUsageResponse.AdditionalRateLimit]?) -> [UsageBucketGroupSnapshot] + { + guard let sparkRateLimit = rateLimits?.first(where: self.isSparkRateLimit) else { return [] } + let session = self.makeWindow(sparkRateLimit.rateLimit?.primaryWindow) + let weekly = self.makeWindow(sparkRateLimit.rateLimit?.secondaryWindow) + var buckets: [UsageBucketSnapshot] = [] + if let session { + buckets.append(UsageBucketSnapshot(id: "codex.spark.session", title: "Session", window: session)) + } + if let weekly { + buckets.append(UsageBucketSnapshot(id: "codex.spark.weekly", title: "Weekly", window: weekly)) + } + guard !buckets.isEmpty else { return [] } + return [UsageBucketGroupSnapshot( + id: "codex.spark", + title: "GPT-5.3-Codex-Spark", + buckets: buckets)] + } + + private static func isSparkRateLimit(_ rateLimit: CodexUsageResponse.AdditionalRateLimit) -> Bool { + let limitName = rateLimit.limitName?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if limitName == "gpt-5.3-codex-spark" { + return true + } + + let meteredFeature = rateLimit.meteredFeature?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return meteredFeature == "codex_bengalfox" + } + private static func resolveAccountEmail(from credentials: CodexOAuthCredentials) -> String? { guard let idToken = credentials.idToken, let payload = UsageFetcher.parseJWT(idToken) diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index f2370e9ca..f5c273917 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -19,6 +19,30 @@ public struct RateWindow: Codable, Equatable, Sendable { } } +public struct UsageBucketSnapshot: Codable, Equatable, Sendable { + public let id: String + public let title: String + public let window: RateWindow + + public init(id: String, title: String, window: RateWindow) { + self.id = id + self.title = title + self.window = window + } +} + +public struct UsageBucketGroupSnapshot: Codable, Equatable, Sendable { + public let id: String + public let title: String + public let buckets: [UsageBucketSnapshot] + + public init(id: String, title: String, buckets: [UsageBucketSnapshot]) { + self.id = id + self.title = title + self.buckets = buckets + } +} + public struct ProviderIdentitySnapshot: Codable, Sendable { public let providerID: UsageProvider? public let accountEmail: String? @@ -51,6 +75,7 @@ public struct UsageSnapshot: Codable, Sendable { public let primary: RateWindow? public let secondary: RateWindow? public let tertiary: RateWindow? + public let usageBucketGroups: [UsageBucketGroupSnapshot] public let providerCost: ProviderCostSnapshot? public let zaiUsage: ZaiUsageSnapshot? public let minimaxUsage: MiniMaxUsageSnapshot? @@ -63,6 +88,7 @@ public struct UsageSnapshot: Codable, Sendable { case primary case secondary case tertiary + case usageBucketGroups case providerCost case openRouterUsage case updatedAt @@ -76,6 +102,7 @@ public struct UsageSnapshot: Codable, Sendable { primary: RateWindow?, secondary: RateWindow?, tertiary: RateWindow? = nil, + usageBucketGroups: [UsageBucketGroupSnapshot] = [], providerCost: ProviderCostSnapshot? = nil, zaiUsage: ZaiUsageSnapshot? = nil, minimaxUsage: MiniMaxUsageSnapshot? = nil, @@ -87,6 +114,7 @@ public struct UsageSnapshot: Codable, Sendable { self.primary = primary self.secondary = secondary self.tertiary = tertiary + self.usageBucketGroups = Self.normalizedUsageBucketGroups(usageBucketGroups) self.providerCost = providerCost self.zaiUsage = zaiUsage self.minimaxUsage = minimaxUsage @@ -101,6 +129,10 @@ public struct UsageSnapshot: Codable, Sendable { self.primary = try container.decodeIfPresent(RateWindow.self, forKey: .primary) self.secondary = try container.decodeIfPresent(RateWindow.self, forKey: .secondary) self.tertiary = try container.decodeIfPresent(RateWindow.self, forKey: .tertiary) + let decodedUsageBucketGroups = try container.decodeIfPresent( + [UsageBucketGroupSnapshot].self, + forKey: .usageBucketGroups) ?? [] + self.usageBucketGroups = Self.normalizedUsageBucketGroups(decodedUsageBucketGroups) self.providerCost = try container.decodeIfPresent(ProviderCostSnapshot.self, forKey: .providerCost) self.zaiUsage = nil // Not persisted, fetched fresh each time self.minimaxUsage = nil // Not persisted, fetched fresh each time @@ -131,6 +163,7 @@ public struct UsageSnapshot: Codable, Sendable { try container.encode(self.primary, forKey: .primary) try container.encode(self.secondary, forKey: .secondary) try container.encode(self.tertiary, forKey: .tertiary) + try container.encode(self.usageBucketGroups, forKey: .usageBucketGroups) try container.encodeIfPresent(self.providerCost, forKey: .providerCost) try container.encodeIfPresent(self.openRouterUsage, forKey: .openRouterUsage) try container.encode(self.updatedAt, forKey: .updatedAt) @@ -184,6 +217,7 @@ public struct UsageSnapshot: Codable, Sendable { primary: self.primary, secondary: self.secondary, tertiary: self.tertiary, + usageBucketGroups: self.usageBucketGroups, providerCost: self.providerCost, zaiUsage: self.zaiUsage, minimaxUsage: self.minimaxUsage, @@ -199,6 +233,16 @@ public struct UsageSnapshot: Codable, Sendable { if scopedIdentity.providerID == identity.providerID { return self } return self.withIdentity(scopedIdentity) } + + private static func normalizedUsageBucketGroups( + _ groups: [UsageBucketGroupSnapshot]) -> [UsageBucketGroupSnapshot] + { + groups.compactMap { group in + let buckets = group.buckets.filter { !$0.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + guard !buckets.isEmpty else { return nil } + return UsageBucketGroupSnapshot(id: group.id, title: group.title, buckets: buckets) + } + } } public struct AccountInfo: Equatable, Sendable { diff --git a/Tests/CodexBarTests/CLISnapshotTests.swift b/Tests/CodexBarTests/CLISnapshotTests.swift index dd755b29f..4017a5fad 100644 --- a/Tests/CodexBarTests/CLISnapshotTests.swift +++ b/Tests/CodexBarTests/CLISnapshotTests.swift @@ -43,6 +43,66 @@ struct CLISnapshotTests { #expect(output.contains("Plan: Pro")) } + @Test + func rendersSparkSectionForCodexWhenLiveSparkUsageExists() { + let now = Date(timeIntervalSince1970: 0) + let snap = UsageSnapshot( + primary: .init( + usedPercent: 12, + windowMinutes: 300, + resetsAt: nil, + resetDescription: "today at 3:00 PM"), + secondary: .init( + usedPercent: 25, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: "Fri at 9:00 AM"), + usageBucketGroups: [ + UsageBucketGroupSnapshot( + id: "codex.spark", + title: "GPT-5.3-Codex-Spark", + buckets: [ + UsageBucketSnapshot( + id: "codex.spark.session", + title: "Session", + window: .init( + usedPercent: 3, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(900), + resetDescription: nil)), + UsageBucketSnapshot( + id: "codex.spark.weekly", + title: "Weekly", + window: .init( + usedPercent: 17, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(1200), + resetDescription: nil)), + ]), + ], + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: nil)) + + let output = CLIRenderer.renderText( + provider: .codex, + snapshot: snap, + credits: nil, + context: RenderContext( + header: "Codex 1.2.3 (oauth)", + status: nil, + useColor: false, + resetStyle: .countdown)) + + #expect(output.contains("GPT-5.3-Codex-Spark")) + #expect(output.contains("Session: 97% left")) + #expect(output.contains("Weekly: 83% left")) + #expect(output.contains("Plan:") == false) + } + @Test func rendersTextSnapshotForClaudeWithoutWeekly() { let snap = UsageSnapshot( @@ -245,6 +305,47 @@ struct CLISnapshotTests { #expect(output.contains("Pace:")) } + @Test + func usageSnapshotRoundTripsUsageBucketGroups() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = UsageSnapshot( + primary: .init(usedPercent: 50, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + usageBucketGroups: [ + UsageBucketGroupSnapshot( + id: "codex.spark", + title: "GPT-5.3-Codex-Spark", + buckets: [ + UsageBucketSnapshot( + id: "codex.spark.session", + title: "Session", + window: .init( + usedPercent: 3, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(900), + resetDescription: nil)), + ]), + UsageBucketGroupSnapshot( + id: "empty.group", + title: "Empty", + buckets: []), + ], + updatedAt: now) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .secondsSince1970 + let data = try encoder.encode(snapshot) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + let decoded = try decoder.decode(UsageSnapshot.self, from: data) + + #expect(decoded.usageBucketGroups.count == 1) + #expect(decoded.usageBucketGroups.first?.id == "codex.spark") + #expect(decoded.usageBucketGroups.first?.buckets.map(\.id) == ["codex.spark.session"]) + } + @Test func rendersJSONPayload() throws { let snap = UsageSnapshot( @@ -282,6 +383,7 @@ struct CLISnapshotTests { #expect(json.contains("status.example.com")) #expect(json.contains("\"primary\"")) #expect(json.contains("\"windowMinutes\":300")) + #expect(json.contains("\"usageBucketGroups\":[]")) #expect(json.contains("1700000000")) } @@ -302,6 +404,7 @@ struct CLISnapshotTests { } #expect(json.contains("\"secondary\":null")) + #expect(json.contains("\"usageBucketGroups\":[]")) } @Test diff --git a/Tests/CodexBarTests/CodexOAuthTests.swift b/Tests/CodexBarTests/CodexOAuthTests.swift index 10a628aa0..079e51d1b 100644 --- a/Tests/CodexBarTests/CodexOAuthTests.swift +++ b/Tests/CodexBarTests/CodexOAuthTests.swift @@ -99,6 +99,100 @@ struct CodexOAuthTests { #expect(snapshot.secondary?.resetsAt != nil) } + @Test + func mapsCodexSparkWindowsFromOAuthAdditionalRateLimits() throws { + let json = """ + { + "rate_limit": { + "primary_window": { + "used_percent": 22, + "reset_at": 1766948068, + "limit_window_seconds": 18000 + }, + "secondary_window": { + "used_percent": 43, + "reset_at": 1767407914, + "limit_window_seconds": 604800 + } + }, + "additional_rate_limits": [ + { + "limit_name": "GPT-5.3-Codex-Spark", + "metered_feature": "codex_bengalfox", + "rate_limit": { + "primary_window": { + "used_percent": 3, + "reset_at": 1766948068, + "limit_window_seconds": 18000 + }, + "secondary_window": { + "used_percent": 17, + "reset_at": 1767407914, + "limit_window_seconds": 604800 + } + } + } + ] + } + """ + let creds = CodexOAuthCredentials( + accessToken: "access", + refreshToken: "refresh", + idToken: nil, + accountId: nil, + lastRefresh: Date()) + let snapshot = try CodexOAuthFetchStrategy._mapUsageForTesting(Data(json.utf8), credentials: creds) + #expect(snapshot.primary?.usedPercent == 22) + #expect(snapshot.secondary?.usedPercent == 43) + #expect(snapshot.usageBucketGroups.count == 1) + let sparkGroup = try #require(snapshot.usageBucketGroups.first) + #expect(sparkGroup.id == "codex.spark") + #expect(sparkGroup.title == "GPT-5.3-Codex-Spark") + #expect(sparkGroup.buckets.map(\.id) == ["codex.spark.session", "codex.spark.weekly"]) + #expect(sparkGroup.buckets.map(\.title) == ["Session", "Weekly"]) + #expect(sparkGroup.buckets.first?.window.usedPercent == 3) + #expect(sparkGroup.buckets.first?.window.windowMinutes == 300) + #expect(sparkGroup.buckets.last?.window.usedPercent == 17) + #expect(sparkGroup.buckets.last?.window.windowMinutes == 10080) + } + + @Test + func ignoresUnrelatedOAuthAdditionalRateLimitsWhenSparkAbsent() throws { + let json = """ + { + "rate_limit": { + "primary_window": { + "used_percent": 22, + "reset_at": 1766948068, + "limit_window_seconds": 18000 + } + }, + "additional_rate_limits": [ + { + "limit_name": "Something else", + "metered_feature": "codex_something_else", + "rate_limit": { + "secondary_window": { + "used_percent": 88, + "reset_at": 1767407914, + "limit_window_seconds": 604800 + } + } + } + ] + } + """ + let creds = CodexOAuthCredentials( + accessToken: "access", + refreshToken: "refresh", + idToken: nil, + accountId: nil, + lastRefresh: Date()) + let snapshot = try CodexOAuthFetchStrategy._mapUsageForTesting(Data(json.utf8), credentials: creds) + #expect(snapshot.primary?.usedPercent == 22) + #expect(snapshot.usageBucketGroups.isEmpty) + } + @Test func resolvesChatGPTUsageURLFromConfig() { let config = "chatgpt_base_url = \"https://chatgpt.com/backend-api/\"\n" diff --git a/Tests/CodexBarTests/MenuCardModelSparkTests.swift b/Tests/CodexBarTests/MenuCardModelSparkTests.swift new file mode 100644 index 000000000..0e54fd88d --- /dev/null +++ b/Tests/CodexBarTests/MenuCardModelSparkTests.swift @@ -0,0 +1,123 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite +struct MenuCardModelSparkTests { + @Test + func showsSparkSessionAndWeeklyMetricsWhenCodexSparkUsagePresent() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 22, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3000), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 40, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(6000), + resetDescription: nil), + usageBucketGroups: [ + UsageBucketGroupSnapshot( + id: "codex.spark", + title: "GPT-5.3-Codex-Spark", + buckets: [ + UsageBucketSnapshot( + id: "codex.spark.session", + title: "Session", + window: RateWindow( + usedPercent: 3, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(5400), + resetDescription: nil)), + UsageBucketSnapshot( + id: "codex.spark.weekly", + title: "Weekly", + window: RateWindow( + usedPercent: 17, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7200), + resetDescription: nil)), + ]), + ], + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: nil)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "codex@example.com", plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.count == 4) + #expect(model.metrics.contains { $0.id == "codex.spark.session" && $0.title == "Session" && $0.percent == 97 }) + #expect(model.metrics.contains { $0.id == "codex.spark.weekly" && $0.title == "Weekly" && $0.percent == 83 }) + + let groups = UsageMenuCardView.metricGroups(metrics: model.metrics) + #expect(groups.count == 2) + let sparkGroup = try #require(groups.last) + #expect(sparkGroup.id == "codex.spark") + #expect(sparkGroup.title == "GPT-5.3-Codex-Spark") + #expect(sparkGroup.metrics.map(\.id) == ["codex.spark.session", "codex.spark.weekly"]) + } + + @Test + func doesNotCreateSupplementalGroupWithoutUsageBucketGroups() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 22, windowMinutes: 300, resetsAt: now, resetDescription: nil), + secondary: RateWindow(usedPercent: 40, windowMinutes: 10080, resetsAt: now, resetDescription: nil), + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: "Pro")) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "codex@example.com", plan: "Pro"), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(UsageMenuCardView.metricGroups(metrics: model.metrics).count == 1) + #expect(model.metrics.contains { $0.groupID != nil } == false) + } +} diff --git a/Tests/CodexBarTests/MenuDescriptorSparkTests.swift b/Tests/CodexBarTests/MenuDescriptorSparkTests.swift new file mode 100644 index 000000000..7ee15e193 --- /dev/null +++ b/Tests/CodexBarTests/MenuDescriptorSparkTests.swift @@ -0,0 +1,144 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite +struct MenuDescriptorSparkTests { + @Test + func codexUsageSectionSeparatesSparkRowsWhenLiveSparkUsageExists() throws { + let suite = "MenuDescriptorSparkTests-live-spark" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.usageBarsShowUsed = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let now = Date() + store._setSnapshotForTesting(UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: now, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: now, resetDescription: nil), + usageBucketGroups: [ + UsageBucketGroupSnapshot( + id: "codex.spark", + title: "GPT-5.3-Codex-Spark", + buckets: [ + UsageBucketSnapshot( + id: "codex.spark.session", + title: "Session", + window: RateWindow( + usedPercent: 3, + windowMinutes: 300, + resetsAt: now, + resetDescription: nil)), + UsageBucketSnapshot( + id: "codex.spark.weekly", + title: "Weekly", + window: RateWindow( + usedPercent: 17, + windowMinutes: 10080, + resetsAt: now, + resetDescription: nil)), + ]), + ], + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: nil)), provider: .codex) + + let descriptor = MenuDescriptor.build( + provider: .codex, + store: store, + settings: settings, + account: AccountInfo(email: "codex@example.com", plan: nil), + updateReady: false, + includeContextualActions: false) + + let textLines = descriptor.sections + .flatMap(\.entries) + .compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + + #expect(textLines.contains("GPT-5.3-Codex-Spark")) + #expect(textLines.contains("Session: 97% left")) + #expect(textLines.contains("Weekly: 83% left")) + } + + @Test + func codexSparkOnlyUsageSectionDoesNotInsertLeadingDivider() throws { + let suite = "MenuDescriptorSparkTests-spark-only" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.usageBarsShowUsed = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let now = Date() + store._setSnapshotForTesting(UsageSnapshot( + primary: nil, + secondary: nil, + usageBucketGroups: [ + UsageBucketGroupSnapshot( + id: "codex.spark", + title: "GPT-5.3-Codex-Spark", + buckets: [ + UsageBucketSnapshot( + id: "codex.spark.session", + title: "Session", + window: RateWindow( + usedPercent: 3, + windowMinutes: 300, + resetsAt: now, + resetDescription: nil)), + ]), + ], + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: nil)), provider: .codex) + + let descriptor = MenuDescriptor.build( + provider: .codex, + store: store, + settings: settings, + account: AccountInfo(email: "codex@example.com", plan: nil), + updateReady: false, + includeContextualActions: false) + + let entries = try #require(descriptor.sections.first?.entries) + #expect(entries.count >= 3) + #expect({ + guard case .text("Codex", .headline) = entries[0] else { return false } + return true + }()) + #expect({ + guard case .text("GPT-5.3-Codex-Spark", .headline) = entries[1] else { return false } + return true + }()) + } +} diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index 3f04c3ff7..85e944756 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -3,6 +3,22 @@ import CodexBarCore import Testing @testable import CodexBar +private func makeCodexSparkUsageBucketGroup() -> UsageBucketGroupSnapshot { + UsageBucketGroupSnapshot( + id: "codex.spark", + title: "GPT-5.3-Codex-Spark", + buckets: [ + UsageBucketSnapshot( + id: "codex.spark.session", + title: "Session", + window: RateWindow(usedPercent: 3, windowMinutes: 300, resetsAt: Date(), resetDescription: nil)), + UsageBucketSnapshot( + id: "codex.spark.weekly", + title: "Weekly", + window: RateWindow(usedPercent: 17, windowMinutes: 10080, resetsAt: Date(), resetDescription: nil)), + ]) +} + @MainActor @Suite struct StatusMenuTests { @@ -634,6 +650,109 @@ struct StatusMenuTests { #expect(try #require(creditsIndex) < costIndex!) } + @Test + func codexMenuCardSeparatesSparkSectionWhenLiveSparkUsageExists() throws { + StatusItemController.menuCardRenderingEnabled = true + StatusItemController.menuRefreshEnabled = false + defer { self.disableMenuCardsForTesting() } + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: false) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setSnapshotForTesting(UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: Date(), resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: Date(), resetDescription: nil), + usageBucketGroups: [makeCodexSparkUsageBucketGroup()], + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: nil)), provider: .codex) + store.credits = CreditsSnapshot(remaining: 42, events: [], updatedAt: Date()) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let ids = self.representedIDs(in: menu) + let usageIndex = try #require(ids.firstIndex(of: "menuCardUsage")) + let sparkIndex = try #require(ids.firstIndex(of: "menuCardUsageGroup-codex.spark")) + let creditsIndex = try #require(ids.firstIndex(of: "menuCardCredits")) + + #expect(usageIndex < sparkIndex) + #expect(sparkIndex < creditsIndex) + } + + @Test + func codexSparkOnlyMenuCardKeepsSeparatorBeforeActions() throws { + StatusItemController.menuCardRenderingEnabled = true + StatusItemController.menuRefreshEnabled = false + defer { self.disableMenuCardsForTesting() } + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: false) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setSnapshotForTesting(UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: Date(), resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: Date(), resetDescription: nil), + usageBucketGroups: [makeCodexSparkUsageBucketGroup()], + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: nil)), provider: .codex) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + let sparkIndex = try #require(menu.items.firstIndex { + ($0.representedObject as? String) == "menuCardUsageGroup-codex.spark" + }) + let separatorIndex = sparkIndex + 1 + #expect(separatorIndex < menu.items.count) + #expect(menu.items[separatorIndex].isSeparatorItem) + } + @Test func showsExtraUsageForClaudeWhenUsingMenuCardSections() { self.disableMenuCardsForTesting() From 4f909e60a0765bb1593b673c3474ad7ca9f679ff Mon Sep 17 00:00:00 2001 From: Bryan Font Date: Thu, 12 Mar 2026 18:30:06 -0400 Subject: [PATCH 5/9] Fix bucket group ID collisions --- Sources/CodexBar/MenuCardView.swift | 44 ++++++- .../CodexBar/StatusItemController+Menu.swift | 2 +- .../MenuCardModelSparkTests.swift | 110 ++++++++++++++++++ Tests/CodexBarTests/StatusMenuTests.swift | 58 +++++++++ 4 files changed, 207 insertions(+), 7 deletions(-) diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 5702a2f73..32478780b 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -52,9 +52,24 @@ struct UsageMenuCardView: View { } struct MetricGroup: Identifiable { + enum Kind { + case builtInPrimary + case providerBucket + } + let id: String let title: String? + let kind: Kind let metrics: [Metric] + + var internalID: String { + switch self.kind { + case .builtInPrimary: + "builtInPrimary" + case .providerBucket: + "providerBucket:\(self.id)" + } + } } enum SubtitleStyle { @@ -98,6 +113,7 @@ struct UsageMenuCardView: View { let model: Model let width: CGFloat @Environment(\.menuItemHighlighted) private var isHighlighted + private static let builtInPrimaryMetricGroupID = "__builtInPrimary" static func popupMetricTitle(provider: UsageProvider, metric: Model.Metric) -> String { if provider == .openrouter, metric.id == "primary" { @@ -111,7 +127,11 @@ struct UsageMenuCardView: View { var groups: [Model.MetricGroup] = [] let primaryMetrics = metrics.filter { $0.groupID == nil } if !primaryMetrics.isEmpty { - groups.append(.init(id: "primary", title: nil, metrics: primaryMetrics)) + groups.append(.init( + id: self.builtInPrimaryMetricGroupID, + title: nil, + kind: .builtInPrimary, + metrics: primaryMetrics)) } var supplementalGroups: [String: [Model.Metric]] = [:] @@ -126,18 +146,30 @@ struct UsageMenuCardView: View { for groupID in supplementalOrder { guard let metrics = supplementalGroups[groupID], !metrics.isEmpty else { continue } - groups.append(.init(id: groupID, title: metrics.first?.groupTitle, metrics: metrics)) + groups.append(.init( + id: groupID, + title: metrics.first?.groupTitle, + kind: .providerBucket, + metrics: metrics)) } return groups } static func primaryMetricGroup(metrics: [Model.Metric]) -> Model.MetricGroup? { - self.metricGroups(metrics: metrics).first { $0.id == "primary" } + self.metricGroups(metrics: metrics).first { $0.kind == .builtInPrimary } } static func supplementalMetricGroups(metrics: [Model.Metric]) -> [Model.MetricGroup] { - self.metricGroups(metrics: metrics).filter { $0.id != "primary" } + self.metricGroups(metrics: metrics).filter { $0.kind == .providerBucket } + } + + static func emptyPrimaryMetricGroup() -> Model.MetricGroup { + .init( + id: self.builtInPrimaryMetricGroupID, + title: nil, + kind: .builtInPrimary, + metrics: []) } static func displayMetricTitle(provider: UsageProvider, metric: Model.Metric) -> String { @@ -173,7 +205,7 @@ struct UsageMenuCardView: View { VStack(alignment: .leading, spacing: 12) { if hasUsage { VStack(alignment: .leading, spacing: 12) { - ForEach(Array(metricGroups.enumerated()), id: \.element.id) { index, group in + ForEach(Array(metricGroups.enumerated()), id: \.element.internalID) { index, group in if index > 0 { Divider() } @@ -505,7 +537,7 @@ struct UsageMenuCardUsageSectionView: View { .font(.subheadline) } } else { - ForEach(Array(metricGroups.enumerated()), id: \.element.id) { index, group in + ForEach(Array(metricGroups.enumerated()), id: \.element.internalID) { index, group in if index > 0 { Divider() } diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 78479c060..5589a25bd 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -867,7 +867,7 @@ extension StatusItemController { if hasUsageBlock { let usageView = UsageMenuCardMetricGroupSectionView( provider: provider, - group: primaryMetricGroup ?? .init(id: "primary", title: nil, metrics: []), + group: primaryMetricGroup ?? UsageMenuCardView.emptyPrimaryMetricGroup(), usageNotes: model.usageNotes, placeholder: model.placeholder, topPadding: 10, diff --git a/Tests/CodexBarTests/MenuCardModelSparkTests.swift b/Tests/CodexBarTests/MenuCardModelSparkTests.swift index 0e54fd88d..0dd87c3df 100644 --- a/Tests/CodexBarTests/MenuCardModelSparkTests.swift +++ b/Tests/CodexBarTests/MenuCardModelSparkTests.swift @@ -120,4 +120,114 @@ struct MenuCardModelSparkTests { #expect(UsageMenuCardView.metricGroups(metrics: model.metrics).count == 1) #expect(model.metrics.contains { $0.groupID != nil } == false) } + + @Test + func preservesProviderOwnedPrimaryBucketGroupAlongsideBuiltInPrimaryGroup() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 22, windowMinutes: 300, resetsAt: now, resetDescription: nil), + secondary: nil, + usageBucketGroups: [ + UsageBucketGroupSnapshot( + id: "primary", + title: "Provider Primary", + buckets: [ + UsageBucketSnapshot( + id: "primary.session", + title: "Session", + window: RateWindow( + usedPercent: 3, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(5400), + resetDescription: nil)), + ]), + ], + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: nil)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "codex@example.com", plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let groups = UsageMenuCardView.metricGroups(metrics: model.metrics) + #expect(groups.count == 2) + #expect(groups.map(\.kind) == [.builtInPrimary, .providerBucket]) + #expect(groups.contains { $0.id == "primary" && $0.kind == .providerBucket }) + #expect(UsageMenuCardView.primaryMetricGroup(metrics: model.metrics)?.kind == .builtInPrimary) + #expect(UsageMenuCardView.supplementalMetricGroups(metrics: model.metrics).map(\.id) == ["primary"]) + } + + @Test + func keepsInternalViewIdentityUniqueWhenProviderGroupMatchesBuiltInSentinel() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 22, windowMinutes: 300, resetsAt: now, resetDescription: nil), + secondary: nil, + usageBucketGroups: [ + UsageBucketGroupSnapshot( + id: "__builtInPrimary", + title: "Provider Sentinel", + buckets: [ + UsageBucketSnapshot( + id: "__builtInPrimary.session", + title: "Session", + window: RateWindow( + usedPercent: 3, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(5400), + resetDescription: nil)), + ]), + ], + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: nil)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "codex@example.com", plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let groups = UsageMenuCardView.metricGroups(metrics: model.metrics) + #expect(groups.map(\.internalID) == ["builtInPrimary", "providerBucket:__builtInPrimary"]) + } } diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index 85e944756..d77267a5e 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -19,6 +19,18 @@ private func makeCodexSparkUsageBucketGroup() -> UsageBucketGroupSnapshot { ]) } +private func makeProviderPrimaryUsageBucketGroup() -> UsageBucketGroupSnapshot { + UsageBucketGroupSnapshot( + id: "primary", + title: "Provider Primary", + buckets: [ + UsageBucketSnapshot( + id: "primary.session", + title: "Session", + window: RateWindow(usedPercent: 3, windowMinutes: 300, resetsAt: Date(), resetDescription: nil)), + ]) +} + @MainActor @Suite struct StatusMenuTests { @@ -753,6 +765,52 @@ struct StatusMenuTests { #expect(menu.items[separatorIndex].isSeparatorItem) } + @Test + func providerOwnedPrimaryBucketGroupStillRendersAsSupplementalSection() throws { + StatusItemController.menuCardRenderingEnabled = true + StatusItemController.menuRefreshEnabled = false + defer { self.disableMenuCardsForTesting() } + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setSnapshotForTesting(UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: Date(), resetDescription: nil), + secondary: nil, + usageBucketGroups: [makeProviderPrimaryUsageBucketGroup()], + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: nil)), provider: .codex) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let ids = self.representedIDs(in: menu) + let usageIndex = try #require(ids.firstIndex(of: "menuCardUsage")) + let providerPrimaryIndex = try #require(ids.firstIndex(of: "menuCardUsageGroup-primary")) + + #expect(usageIndex < providerPrimaryIndex) + } + @Test func showsExtraUsageForClaudeWhenUsingMenuCardSections() { self.disableMenuCardsForTesting() From b8c8cd4b2519b902fa6da16218f39d20358b2cb0 Mon Sep 17 00:00:00 2001 From: Bryan Font Date: Thu, 12 Mar 2026 19:59:20 -0400 Subject: [PATCH 6/9] Improve cost chart detail rendering --- .../CodexBar/CostHistoryChartMenuView.swift | 174 ++++++++++++++---- Sources/CodexBarCLI/CLICostCommand.swift | 7 +- Sources/CodexBarCore/CostUsageModels.swift | 6 +- .../CostUsage/CostUsageScanner+Claude.swift | 19 +- .../Vendored/CostUsage/CostUsageScanner.swift | 19 +- Tests/CodexBarTests/CLICostTests.swift | 8 +- .../CostUsageDecodingTests.swift | 24 +++ .../CostUsageModelBreakdownTests.swift | 72 ++++++++ 8 files changed, 282 insertions(+), 47 deletions(-) create mode 100644 Tests/CodexBarTests/CostUsageModelBreakdownTests.swift diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index 5852d05ac..ef5ed808c 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -20,6 +20,18 @@ struct CostHistoryChartMenuView: View { } } + private struct DetailRow: Identifiable { + let id: String + let title: String + let subtitle: String? + let accentColor: Color + } + + private struct DetailContent { + let primary: String + let rows: [DetailRow] + } + private let provider: UsageProvider private let daily: [DailyEntry] private let totalCostUSD: Double? @@ -88,22 +100,48 @@ struct CostHistoryChartMenuView: View { } } - let detail = self.detailLines(model: model) - VStack(alignment: .leading, spacing: 0) { + let detail = self.detailContent(model: model) + VStack(alignment: .leading, spacing: Self.detailSpacing) { Text(detail.primary) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) - .frame(height: 16, alignment: .leading) - Text(detail.secondary ?? " ") - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - .frame(height: 16, alignment: .leading) - .opacity(detail.secondary == nil ? 0 : 1) + .frame(height: Self.detailPrimaryLineHeight, alignment: .leading) + ForEach(detail.rows) { row in + HStack(alignment: .top, spacing: 8) { + Rectangle() + .fill(row.accentColor) + .frame(width: 2, height: row.subtitle == nil ? 14 : Self.detailRowHeight) + .padding(.top, 1) + + VStack(alignment: .leading, spacing: 1) { + Text(row.title) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + if let subtitle = row.subtitle { + Text(subtitle) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.tail) + } + } + } + .frame(height: Self.detailRowHeight, alignment: .leading) + } + ForEach(0.. Double { maxValue * 0.05 @@ -150,6 +193,7 @@ struct CostHistoryChartMenuView: View { var peak: (key: String, costUSD: Double)? var maxCostUSD: Double = 0 + var maxRenderedBreakdownRows = 0 for entry in sorted { guard let costUSD = entry.costUSD, costUSD >= 0 else { continue } guard let date = self.dateFromDayKey(entry.date) else { continue } @@ -158,6 +202,7 @@ struct CostHistoryChartMenuView: View { pointsByKey[entry.date] = point entriesByKey[entry.date] = entry dateKeys.append((entry.date, date)) + maxRenderedBreakdownRows = max(maxRenderedBreakdownRows, Self.renderedBreakdownRowCount(for: entry)) if let cur = peak { if costUSD > cur.costUSD { peak = (entry.date, costUSD) } } else { @@ -181,7 +226,8 @@ struct CostHistoryChartMenuView: View { axisDates: axisDates, barColor: barColor, peakKey: peak?.key, - maxCostUSD: maxCostUSD) + maxCostUSD: maxCostUSD, + maxRenderedBreakdownRows: maxRenderedBreakdownRows) } private static func barColor(for provider: UsageProvider) -> Color { @@ -211,6 +257,21 @@ struct CostHistoryChartMenuView: View { return model.pointsByDateKey[key] } + private static func renderedBreakdownRowCount(for entry: DailyEntry) -> Int { + guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return 0 } + if breakdown.count > self.maxVisibleDetailLines { + return self.maxVisibleDetailLines + } + return breakdown.count + } + + private static func detailBlockHeight(maxBreakdownRows: Int) -> CGFloat { + guard maxBreakdownRows > 0 else { return self.detailPrimaryLineHeight } + return self.detailPrimaryLineHeight + + (CGFloat(maxBreakdownRows) * self.detailRowHeight) + + (CGFloat(maxBreakdownRows) * self.detailSpacing) + } + private func selectionBandRect(model: Model, proxy: ChartProxy, geo: GeometryProxy) -> CGRect? { guard let key = self.selectedDateKey else { return nil } guard let plotAnchor = proxy.plotFrame else { return nil } @@ -286,43 +347,84 @@ struct CostHistoryChartMenuView: View { return best?.key } - private func detailLines(model: Model) -> (primary: String, secondary: String?) { + private func detailContent(model: Model) -> DetailContent { guard let key = self.selectedDateKey, let point = model.pointsByDateKey[key], let date = Self.dateFromDayKey(key) else { - return ("Hover a bar for details", nil) + return DetailContent(primary: "Hover a bar for details", rows: []) } let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) let cost = UsageFormatter.usdString(point.costUSD) - if let tokens = point.totalTokens { - let primary = "\(dayLabel): \(cost) · \(UsageFormatter.tokenCountString(tokens)) tokens" - let secondary = self.topModelsText(key: key, model: model) - return (primary, secondary) + let primary = if let tokens = point.totalTokens { + "\(dayLabel): \(cost) · \(UsageFormatter.tokenCountString(tokens)) tokens" + } else { + "\(dayLabel): \(cost)" } - let primary = "\(dayLabel): \(cost)" - let secondary = self.topModelsText(key: key, model: model) - return (primary, secondary) + return DetailContent(primary: primary, rows: self.breakdownRows(key: key, model: model)) } - private func topModelsText(key: String, model: Model) -> String? { - 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, 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) - } + private func breakdownRows(key: String, model: Model) -> [DetailRow] { + guard let entry = model.entriesByDateKey[key] else { return [] } + guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return [] } + + let sorted = breakdown .sorted { lhs, rhs in - if lhs.costUSD == rhs.costUSD { return lhs.name < rhs.name } - return lhs.costUSD > rhs.costUSD + let lCost = lhs.costUSD ?? -1 + let rCost = rhs.costUSD ?? -1 + if lCost != rCost { return lCost > rCost } + let lTokens = lhs.totalTokens ?? -1 + let rTokens = rhs.totalTokens ?? -1 + if lTokens != rTokens { return lTokens > rTokens } + return lhs.modelName < rhs.modelName + } + + let visibleLimit = sorted.count > Self.maxVisibleDetailLines + ? (Self.maxVisibleDetailLines - 1) + : sorted.count + let visible = Array(sorted.prefix(visibleLimit)) + var rows = visible.enumerated().map { index, item in + DetailRow( + id: "\(item.modelName)-\(index)", + title: UsageFormatter.modelDisplayName(item.modelName), + subtitle: Self.breakdownValueText(costUSD: item.costUSD, totalTokens: item.totalTokens), + accentColor: model.barColor.opacity(Self.breakdownAccentOpacity(for: index))) + } + + let hidden = Array(sorted.dropFirst(visibleLimit)) + if !hidden.isEmpty { + let hiddenCost = hidden.reduce(0.0) { partial, item in + partial + (item.costUSD ?? 0) + } + let hiddenTokens = hidden.reduce(0) { partial, item in + partial + (item.totalTokens ?? 0) } - .prefix(3) - .map { "\($0.name) \($0.detail)" } - guard !parts.isEmpty else { return nil } - return "Top: \(parts.joined(separator: " · "))" + rows.append(DetailRow( + id: "overflow", + title: hidden.count == 1 ? "1 more model" : "\(hidden.count) more models", + subtitle: Self.breakdownValueText( + costUSD: hiddenCost > 0 ? hiddenCost : nil, + totalTokens: hiddenTokens > 0 ? hiddenTokens : nil), + accentColor: Color(nsColor: .tertiaryLabelColor).opacity(0.55))) + } + + return rows + } + + private static func breakdownAccentOpacity(for index: Int) -> Double { + let opacity = 0.75 - (Double(index) * 0.12) + return max(0.3, opacity) + } + + private static func breakdownValueText(costUSD: Double?, totalTokens: Int?) -> String? { + var parts: [String] = [] + if let costUSD, costUSD > 0 { + parts.append(UsageFormatter.usdString(costUSD)) + } + if let totalTokens, totalTokens > 0 { + parts.append("\(UsageFormatter.tokenCountString(totalTokens)) tokens") + } + return parts.isEmpty ? nil : parts.joined(separator: " · ") } } diff --git a/Sources/CodexBarCLI/CLICostCommand.swift b/Sources/CodexBarCLI/CLICostCommand.swift index 665f0977d..c3b82b0e2 100644 --- a/Sources/CodexBarCLI/CLICostCommand.swift +++ b/Sources/CodexBarCLI/CLICostCommand.swift @@ -117,7 +117,10 @@ extension CodexBarCLI { costUSD: entry.costUSD, modelsUsed: entry.modelsUsed, modelBreakdowns: entry.modelBreakdowns?.map { breakdown in - CostModelBreakdownPayload(modelName: breakdown.modelName, costUSD: breakdown.costUSD) + CostModelBreakdownPayload( + modelName: breakdown.modelName, + costUSD: breakdown.costUSD, + totalTokens: breakdown.totalTokens) }) } ?? [] @@ -272,10 +275,12 @@ struct CostDailyEntryPayload: Encodable { struct CostModelBreakdownPayload: Encodable { let modelName: String let costUSD: Double? + let totalTokens: Int? private enum CodingKeys: String, CodingKey { case modelName case costUSD = "cost" + case totalTokens } } diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index 60f5e7598..2b2e4e0f0 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -29,11 +29,13 @@ public struct CostUsageDailyReport: Sendable, Decodable { public struct ModelBreakdown: Sendable, Decodable, Equatable { public let modelName: String public let costUSD: Double? + public let totalTokens: Int? private enum CodingKeys: String, CodingKey { case modelName case costUSD case cost + case totalTokens } public init(from decoder: Decoder) throws { @@ -42,11 +44,13 @@ public struct CostUsageDailyReport: Sendable, Decodable { self.costUSD = try container.decodeIfPresent(Double.self, forKey: .costUSD) ?? container.decodeIfPresent(Double.self, forKey: .cost) + self.totalTokens = try container.decodeIfPresent(Int.self, forKey: .totalTokens) } - public init(modelName: String, costUSD: Double?) { + public init(modelName: String, costUSD: Double?, totalTokens: Int? = nil) { self.modelName = modelName self.costUSD = costUSD + self.totalTokens = totalTokens } } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 5e32060b0..97c86b8fd 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -513,6 +513,7 @@ extension CostUsageScanner { let cacheCreate = packed[safe: 2] ?? 0 let output = packed[safe: 3] ?? 0 let cachedCost = packed[safe: 4] ?? 0 + let modelTotal = input + cacheRead + cacheCreate + output // Cache tokens are tracked separately; totalTokens includes input + cache. dayInput += input @@ -528,15 +529,25 @@ extension CostUsageScanner { cacheReadInputTokens: cacheRead, cacheCreationInputTokens: cacheCreate, outputTokens: output) - breakdown.append(CostUsageDailyReport.ModelBreakdown(modelName: model, costUSD: cost)) + breakdown.append(CostUsageDailyReport.ModelBreakdown( + modelName: model, + costUSD: cost, + totalTokens: modelTotal)) if let cost { dayCost += cost dayCostSeen = true } } - breakdown.sort { lhs, rhs in (rhs.costUSD ?? -1) < (lhs.costUSD ?? -1) } - let top = Array(breakdown.prefix(3)) + breakdown.sort { lhs, rhs in + let lCost = lhs.costUSD ?? -1 + let rCost = rhs.costUSD ?? -1 + if lCost != rCost { return lCost > rCost } + let lTokens = lhs.totalTokens ?? -1 + let rTokens = rhs.totalTokens ?? -1 + if lTokens != rTokens { return lTokens > rTokens } + return lhs.modelName < rhs.modelName + } let dayTotal = dayInput + dayCacheRead + dayCacheCreate + dayOutput let entryCost = dayCostSeen ? dayCost : nil @@ -549,7 +560,7 @@ extension CostUsageScanner { totalTokens: dayTotal, costUSD: entryCost, modelsUsed: modelNames, - modelBreakdowns: top)) + modelBreakdowns: breakdown)) totalInput += dayInput totalOutput += dayOutput diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 8e632b18d..cebdcb425 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -422,6 +422,7 @@ enum CostUsageScanner { let input = packed[safe: 0] ?? 0 let cached = packed[safe: 1] ?? 0 let output = packed[safe: 2] ?? 0 + let modelTotal = input + output dayInput += input dayOutput += output @@ -431,15 +432,25 @@ enum CostUsageScanner { inputTokens: input, cachedInputTokens: cached, outputTokens: output) - breakdown.append(CostUsageDailyReport.ModelBreakdown(modelName: model, costUSD: cost)) + breakdown.append(CostUsageDailyReport.ModelBreakdown( + modelName: model, + costUSD: cost, + totalTokens: modelTotal)) if let cost { dayCost += cost dayCostSeen = true } } - breakdown.sort { lhs, rhs in (rhs.costUSD ?? -1) < (lhs.costUSD ?? -1) } - let top = Array(breakdown.prefix(3)) + breakdown.sort { lhs, rhs in + let lCost = lhs.costUSD ?? -1 + let rCost = rhs.costUSD ?? -1 + if lCost != rCost { return lCost > rCost } + let lTokens = lhs.totalTokens ?? -1 + let rTokens = rhs.totalTokens ?? -1 + if lTokens != rTokens { return lTokens > rTokens } + return lhs.modelName < rhs.modelName + } let dayTotal = dayInput + dayOutput let entryCost = dayCostSeen ? dayCost : nil @@ -450,7 +461,7 @@ enum CostUsageScanner { totalTokens: dayTotal, costUSD: entryCost, modelsUsed: modelNames, - modelBreakdowns: top)) + modelBreakdowns: breakdown)) totalInput += dayInput totalOutput += dayOutput diff --git a/Tests/CodexBarTests/CLICostTests.swift b/Tests/CodexBarTests/CLICostTests.swift index 438506638..30f00a2b5 100644 --- a/Tests/CodexBarTests/CLICostTests.swift +++ b/Tests/CodexBarTests/CLICostTests.swift @@ -57,7 +57,10 @@ struct CLICostTests { costUSD: 0.01, modelsUsed: ["claude-sonnet-4-20250514"], modelBreakdowns: [ - CostModelBreakdownPayload(modelName: "claude-sonnet-4-20250514", costUSD: 0.01), + CostModelBreakdownPayload( + modelName: "claude-sonnet-4-20250514", + costUSD: 0.01, + totalTokens: 15), ]), ], totals: CostTotalsPayload( @@ -83,6 +86,9 @@ struct CLICostTests { #expect(json.contains("\"totals\"")) #expect(json.contains("\"cacheReadTokens\":2")) #expect(json.contains("\"cacheCreationTokens\":3")) + #expect(json + .contains( + "\"modelBreakdowns\":[{\"modelName\":\"claude-sonnet-4-20250514\",\"cost\":0.01,\"totalTokens\":15}]")) #expect(json.contains("\"totalCost\"")) #expect(json.contains("1700000000")) } diff --git a/Tests/CodexBarTests/CostUsageDecodingTests.swift b/Tests/CodexBarTests/CostUsageDecodingTests.swift index 677d4fb85..f6279ce3f 100644 --- a/Tests/CodexBarTests/CostUsageDecodingTests.swift +++ b/Tests/CodexBarTests/CostUsageDecodingTests.swift @@ -164,6 +164,30 @@ struct CostUsageDecodingTests { #expect(report.data[0].modelsUsed == ["a-model", "m-model", "z-model"]) } + @Test + func decodesModelBreakdownTokenTotals() throws { + let json = """ + { + "daily": [ + { + "date": "Dec 20, 2025", + "totalTokens": 30, + "costUSD": 0.12, + "modelBreakdowns": [ + { "modelName": "gpt-5.2", "cost": 0.10, "totalTokens": 24 }, + { "modelName": "gpt-5.2-mini", "cost": 0.02, "totalTokens": 6 } + ] + } + ] + } + """ + + let report = try JSONDecoder().decode(CostUsageDailyReport.self, from: Data(json.utf8)) + let breakdown = try #require(report.data[0].modelBreakdowns) + #expect(breakdown.map(\.modelName) == ["gpt-5.2", "gpt-5.2-mini"]) + #expect(breakdown.map(\.totalTokens) == [24, 6]) + } + @Test func decodesDailyReportLegacyFormatWithEmptyModelMapAsNil() throws { let json = """ diff --git a/Tests/CodexBarTests/CostUsageModelBreakdownTests.swift b/Tests/CodexBarTests/CostUsageModelBreakdownTests.swift new file mode 100644 index 000000000..b56789eff --- /dev/null +++ b/Tests/CodexBarTests/CostUsageModelBreakdownTests.swift @@ -0,0 +1,72 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite +struct CostUsageModelBreakdownTests { + @Test + func codexScannerKeepsAllModelBreakdownsWithTokenTotals() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2025, month: 12, day: 20) + let models: [(raw: String, input: Int, cached: Int, output: Int)] = [ + ("openai/gpt-5.2-codex", 80, 20, 10), + ("openai/gpt-5.2-mini", 50, 0, 5), + ("openai/o4-mini", 30, 0, 3), + ("openai/o3", 20, 0, 2), + ("openai/gpt-4.1", 10, 0, 1), + ] + + var events: [[String: Any]] = [] + for (index, model) in models.enumerated() { + let turnTimestamp = env.isoString(for: day.addingTimeInterval(TimeInterval(index * 2))) + let tokenTimestamp = env.isoString(for: day.addingTimeInterval(TimeInterval((index * 2) + 1))) + events.append([ + "type": "turn_context", + "timestamp": turnTimestamp, + "payload": [ + "model": model.raw, + ], + ]) + events.append([ + "type": "event_msg", + "timestamp": tokenTimestamp, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": model.input, + "cached_input_tokens": model.cached, + "output_tokens": model.output, + ], + "model": model.raw, + ], + ], + ]) + } + + _ = try env.writeCodexSessionFile( + day: day, + filename: "breakdowns.jsonl", + contents: env.jsonl(events)) + + 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) + + let breakdown = try #require(report.data.first?.modelBreakdowns) + #expect(breakdown.count == models.count) + #expect(breakdown.allSatisfy { $0.totalTokens != nil }) + #expect(breakdown.map(\.totalTokens).compactMap(\.self).sorted() == [11, 22, 33, 55, 90]) + } +} From c8e7bdf476d8b5acb3217b0d151e6f818b7f661e Mon Sep 17 00:00:00 2001 From: Bryan Font Date: Fri, 13 Mar 2026 09:27:30 -0400 Subject: [PATCH 7/9] Meld cost chart tests into stacked branch --- Tests/CodexBarTests/CLICostTests.swift | 11 ++++++----- .../CostUsageModelBreakdownTests.swift | 18 ++++++++---------- .../CodexBarTests/CostUsageScannerTests.swift | 5 ++++- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/Tests/CodexBarTests/CLICostTests.swift b/Tests/CodexBarTests/CLICostTests.swift index 30f00a2b5..6d14ecef7 100644 --- a/Tests/CodexBarTests/CLICostTests.swift +++ b/Tests/CodexBarTests/CLICostTests.swift @@ -86,9 +86,10 @@ struct CLICostTests { #expect(json.contains("\"totals\"")) #expect(json.contains("\"cacheReadTokens\":2")) #expect(json.contains("\"cacheCreationTokens\":3")) - #expect(json - .contains( - "\"modelBreakdowns\":[{\"modelName\":\"claude-sonnet-4-20250514\",\"cost\":0.01,\"totalTokens\":15}]")) + #expect(json.contains("\"modelBreakdowns\"")) + #expect(json.contains("\"modelName\":\"claude-sonnet-4-20250514\"")) + #expect(json.contains("\"cost\":0.01")) + #expect(json.contains("\"totalTokens\":15")) #expect(json.contains("\"totalCost\"")) #expect(json.contains("1700000000")) } @@ -114,8 +115,8 @@ struct CLICostTests { 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), + CostModelBreakdownPayload(modelName: "gpt-5.3-codex-spark", costUSD: 0, totalTokens: 20), + CostModelBreakdownPayload(modelName: "gpt-5.2-codex", costUSD: 1.23, totalTokens: 135), ]), ], totals: CostTotalsPayload( diff --git a/Tests/CodexBarTests/CostUsageModelBreakdownTests.swift b/Tests/CodexBarTests/CostUsageModelBreakdownTests.swift index b56789eff..9252ef1ce 100644 --- a/Tests/CodexBarTests/CostUsageModelBreakdownTests.swift +++ b/Tests/CodexBarTests/CostUsageModelBreakdownTests.swift @@ -18,18 +18,16 @@ struct CostUsageModelBreakdownTests { ("openai/gpt-4.1", 10, 0, 1), ] - var events: [[String: Any]] = [] for (index, model) in models.enumerated() { let turnTimestamp = env.isoString(for: day.addingTimeInterval(TimeInterval(index * 2))) let tokenTimestamp = env.isoString(for: day.addingTimeInterval(TimeInterval((index * 2) + 1))) - events.append([ + let events: [[String: Any]] = [[ "type": "turn_context", "timestamp": turnTimestamp, "payload": [ "model": model.raw, ], - ]) - events.append([ + ], [ "type": "event_msg", "timestamp": tokenTimestamp, "payload": [ @@ -43,13 +41,13 @@ struct CostUsageModelBreakdownTests { "model": model.raw, ], ], - ]) - } + ]] - _ = try env.writeCodexSessionFile( - day: day, - filename: "breakdowns.jsonl", - contents: env.jsonl(events)) + _ = try env.writeCodexSessionFile( + day: day, + filename: "breakdowns-\(index).jsonl", + contents: env.jsonl(events)) + } var options = CostUsageScanner.Options( codexSessionsRoot: env.codexSessionsRoot, diff --git a/Tests/CodexBarTests/CostUsageScannerTests.swift b/Tests/CodexBarTests/CostUsageScannerTests.swift index b1107a6c9..29faad333 100644 --- a/Tests/CodexBarTests/CostUsageScannerTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerTests.swift @@ -58,7 +58,10 @@ struct CostUsageScannerTests { #expect(first.data.count == 1) #expect(first.data[0].modelsUsed == ["gpt-5.2-codex"]) #expect(first.data[0].modelBreakdowns == [ - CostUsageDailyReport.ModelBreakdown(modelName: "gpt-5.2-codex", costUSD: first.data[0].costUSD), + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.2-codex", + costUSD: first.data[0].costUSD, + totalTokens: 110), ]) #expect(first.data[0].totalTokens == 110) #expect((first.data[0].costUSD ?? 0) > 0) From bc2b8c0f584bf72b165fe2a8822c99c4fe0bc665 Mon Sep 17 00:00:00 2001 From: Bryan Font Date: Fri, 13 Mar 2026 09:53:46 -0400 Subject: [PATCH 8/9] Restore partial cost chart semantics --- .../CodexBar/CostHistoryChartMenuView.swift | 194 +++++++++++++----- Sources/CodexBarCLI/CLICostCommand.swift | 25 ++- .../CostUsage/CostUsageScanner+Codex.swift | 2 +- .../Vendored/CostUsage/CostUsageScanner.swift | 11 +- Tests/CodexBarTests/CLICostTests.swift | 33 +++ .../CostHistoryChartMenuViewTests.swift | 143 +++++++++++++ ...tUsageScannerCodexCostSemanticsTests.swift | 137 +++++++++++++ 7 files changed, 493 insertions(+), 52 deletions(-) create mode 100644 Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift create mode 100644 Tests/CodexBarTests/CostUsageScannerCodexCostSemanticsTests.swift diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index ef5ed808c..594669196 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -6,30 +6,33 @@ import SwiftUI struct CostHistoryChartMenuView: View { typealias DailyEntry = CostUsageDailyReport.Entry - private struct Point: Identifiable { + struct Point: Identifiable { let id: String let date: Date - let costUSD: Double + let displayCostUSD: Double + let actualCostUSD: Double? let totalTokens: Int? - init(date: Date, costUSD: Double, totalTokens: Int?) { + init(date: Date, displayCostUSD: Double, actualCostUSD: Double?, totalTokens: Int?) { self.date = date - self.costUSD = costUSD + self.displayCostUSD = displayCostUSD + self.actualCostUSD = actualCostUSD self.totalTokens = totalTokens - self.id = "\(Int(date.timeIntervalSince1970))-\(costUSD)" + self.id = "\(Int(date.timeIntervalSince1970))-\(displayCostUSD)" } } - private struct DetailRow: Identifiable { + struct DetailModelLine: Identifiable { let id: String + let text: String let title: String let subtitle: String? let accentColor: Color } - private struct DetailContent { + struct DetailContent { let primary: String - let rows: [DetailRow] + let models: [DetailModelLine] } private let provider: UsageProvider @@ -57,15 +60,16 @@ struct CostHistoryChartMenuView: View { ForEach(model.points) { point in BarMark( x: .value("Day", point.date, unit: .day), - y: .value("Cost", point.costUSD)) + y: .value("Cost", point.displayCostUSD)) .foregroundStyle(model.barColor) } if let peak = Self.peakPoint(model: model) { - let capStart = max(peak.costUSD - Self.capHeight(maxValue: model.maxCostUSD), 0) + let peakCostUSD = peak.actualCostUSD ?? 0 + let capStart = max(peakCostUSD - Self.capHeight(maxValue: model.maxCostUSD), 0) BarMark( x: .value("Day", peak.date, unit: .day), yStart: .value("Cap start", capStart), - yEnd: .value("Cap end", peak.costUSD)) + yEnd: .value("Cap end", peakCostUSD)) .foregroundStyle(Color(nsColor: .systemYellow)) } } @@ -100,7 +104,7 @@ struct CostHistoryChartMenuView: View { } } - let detail = self.detailContent(model: model) + let detail = Self.detailContent(selectedDateKey: self.selectedDateKey, model: model) VStack(alignment: .leading, spacing: Self.detailSpacing) { Text(detail.primary) .font(.caption) @@ -108,7 +112,7 @@ struct CostHistoryChartMenuView: View { .lineLimit(1) .truncationMode(.tail) .frame(height: Self.detailPrimaryLineHeight, alignment: .leading) - ForEach(detail.rows) { row in + ForEach(detail.models) { row in HStack(alignment: .top, spacing: 8) { Rectangle() .fill(row.accentColor) @@ -132,7 +136,7 @@ struct CostHistoryChartMenuView: View { } .frame(height: Self.detailRowHeight, alignment: .leading) } - ForEach(0.. Model { + static func makeModel(provider: UsageProvider, daily: [DailyEntry]) -> Model { let sorted = daily.sorted { lhs, rhs in lhs.date < rhs.date } + var entriesByKey: [String: DailyEntry] = [:] + entriesByKey.reserveCapacity(sorted.count) + for entry in sorted { + entriesByKey[entry.date] = entry + } + var points: [Point] = [] points.reserveCapacity(sorted.count) var pointsByKey: [String: Point] = [:] pointsByKey.reserveCapacity(sorted.count) - var entriesByKey: [String: DailyEntry] = [:] - entriesByKey.reserveCapacity(sorted.count) - var dateKeys: [(key: String, date: Date)] = [] dateKeys.reserveCapacity(sorted.count) var peak: (key: String, costUSD: Double)? var maxCostUSD: Double = 0 - var maxRenderedBreakdownRows = 0 for entry in sorted { - guard let costUSD = entry.costUSD, costUSD >= 0 else { continue } + if let displayCostUSD = Self.displayCostUSD(for: entry), displayCostUSD > 0 { + maxCostUSD = max(maxCostUSD, displayCostUSD) + } + } + + var maxDetailLineCount = 0 + for entry in sorted { + maxDetailLineCount = max(maxDetailLineCount, Self.renderedBreakdownRowCount(for: entry)) + guard let displayCostUSD = Self.displayCostUSD(for: entry) else { continue } guard let date = self.dateFromDayKey(entry.date) else { continue } - let point = Point(date: date, costUSD: costUSD, totalTokens: entry.totalTokens) + let point = Point( + date: date, + displayCostUSD: displayCostUSD, + actualCostUSD: entry.costUSD, + totalTokens: entry.totalTokens) points.append(point) pointsByKey[entry.date] = point - entriesByKey[entry.date] = entry dateKeys.append((entry.date, date)) - maxRenderedBreakdownRows = max(maxRenderedBreakdownRows, Self.renderedBreakdownRowCount(for: entry)) - if let cur = peak { - if costUSD > cur.costUSD { peak = (entry.date, costUSD) } - } else { - peak = (entry.date, costUSD) + if displayCostUSD > 0 { + if let cur = peak { + if displayCostUSD > cur.costUSD { peak = (entry.date, displayCostUSD) } + } else { + peak = (entry.date, displayCostUSD) + } } - maxCostUSD = max(maxCostUSD, costUSD) } let axisDates: [Date] = { @@ -227,7 +244,7 @@ struct CostHistoryChartMenuView: View { barColor: barColor, peakKey: peak?.key, maxCostUSD: maxCostUSD, - maxRenderedBreakdownRows: maxRenderedBreakdownRows) + maxDetailLineCount: maxDetailLineCount) } private static func barColor(for provider: UsageProvider) -> Color { @@ -347,25 +364,60 @@ struct CostHistoryChartMenuView: View { return best?.key } - private func detailContent(model: Model) -> DetailContent { - guard let key = self.selectedDateKey, + static func detailContent(selectedDateKey: String?, model: Model) -> DetailContent { + guard let key = selectedDateKey, let point = model.pointsByDateKey[key], - let date = Self.dateFromDayKey(key) + let date = self.dateFromDayKey(key) else { - return DetailContent(primary: "Hover a bar for details", rows: []) + return DetailContent(primary: "Hover a bar for details", models: []) } let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) - let cost = UsageFormatter.usdString(point.costUSD) - let primary = if let tokens = point.totalTokens { - "\(dayLabel): \(cost) · \(UsageFormatter.tokenCountString(tokens)) tokens" + let partial = Self.hasUnpricedModels(key: key, model: model) ? " partial" : "" + let primary = if let actualCostUSD = point.actualCostUSD, actualCostUSD > 0 { + "\(dayLabel): \(UsageFormatter.usdString(actualCostUSD))\(partial)" + } else if point.displayCostUSD > 0 { + "\(dayLabel): \(UsageFormatter.usdString(point.displayCostUSD))\(partial)" + } else if point.totalTokens ?? 0 > 0 { + "\(dayLabel): No priced cost data" } else { - "\(dayLabel): \(cost)" + "\(dayLabel): No cost data" } - return DetailContent(primary: primary, rows: self.breakdownRows(key: key, model: model)) + + let primaryWithTokens = if let tokens = point.totalTokens { + "\(primary) · \(UsageFormatter.tokenCountString(tokens)) tokens" + } else { + primary + } + return DetailContent( + primary: primaryWithTokens, + models: Self.detailModelLines(key: key, model: model)) } - private func breakdownRows(key: String, model: Model) -> [DetailRow] { + private static func hasUnpricedModels(key: String, model: Model) -> Bool { + guard let entry = model.entriesByDateKey[key], + let breakdown = entry.modelBreakdowns + else { + return false + } + return breakdown.contains { $0.costUSD == nil } + } + + private static func displayCostUSD(for entry: DailyEntry) -> Double? { + if let actualCostUSD = entry.costUSD, actualCostUSD > 0 { + return actualCostUSD + } + guard let breakdown = entry.modelBreakdowns else { return nil } + let subtotal = breakdown.reduce(0.0) { partial, item in + partial + max(0, item.costUSD ?? 0) + } + if subtotal > 0 { + return subtotal + } + return (entry.totalTokens ?? 0) > 0 ? 0 : nil + } + + private static func detailModelLines(key: String, model: Model) -> [DetailModelLine] { guard let entry = model.entriesByDateKey[key] else { return [] } guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return [] } @@ -385,10 +437,16 @@ struct CostHistoryChartMenuView: View { : sorted.count let visible = Array(sorted.prefix(visibleLimit)) var rows = visible.enumerated().map { index, item in - DetailRow( + let title = Self.detailModelDisplayName(item.modelName) + let subtitle = Self.breakdownValueText( + costUSD: item.costUSD, + totalTokens: item.totalTokens, + includeUnpricedLabel: item.costUSD == nil) + return DetailModelLine( id: "\(item.modelName)-\(index)", - title: UsageFormatter.modelDisplayName(item.modelName), - subtitle: Self.breakdownValueText(costUSD: item.costUSD, totalTokens: item.totalTokens), + text: Self.detailLineText(title: title, costUSD: item.costUSD, totalTokens: item.totalTokens), + title: title, + subtitle: subtitle, accentColor: model.barColor.opacity(Self.breakdownAccentOpacity(for: index))) } @@ -400,9 +458,11 @@ struct CostHistoryChartMenuView: View { let hiddenTokens = hidden.reduce(0) { partial, item in partial + (item.totalTokens ?? 0) } - rows.append(DetailRow( + let overflowTitle = hidden.count == 1 ? "1 more model" : "\(hidden.count) more models" + rows.append(DetailModelLine( id: "overflow", - title: hidden.count == 1 ? "1 more model" : "\(hidden.count) more models", + text: overflowTitle, + title: overflowTitle, subtitle: Self.breakdownValueText( costUSD: hiddenCost > 0 ? hiddenCost : nil, totalTokens: hiddenTokens > 0 ? hiddenTokens : nil), @@ -417,10 +477,48 @@ struct CostHistoryChartMenuView: View { return max(0.3, opacity) } - private static func breakdownValueText(costUSD: Double?, totalTokens: Int?) -> String? { + private static func detailLineText(title: String, costUSD: Double?, totalTokens: Int?) -> String { + let tokensText = totalTokens.map { " · \(UsageFormatter.tokenCountString($0)) tokens" } ?? "" + if let costUSD, costUSD > 0 { + return "\(title): \(UsageFormatter.usdString(costUSD))\(tokensText)" + } + return "\(title): unpriced\(tokensText)" + } + + private static func detailModelDisplayName(_ raw: String) -> String { + let cleaned = UsageFormatter.modelDisplayName(raw) + let lower = cleaned.lowercased() + if lower == "unknown" { + return "Unknown model" + } + guard lower.hasPrefix("gpt-") else { + return cleaned + } + + let remainder = lower.dropFirst("gpt-".count) + let parts = remainder.split(separator: "-", omittingEmptySubsequences: true) + guard let version = parts.first else { + return cleaned + } + let suffix = parts.dropFirst().map { part in + String(part.prefix(1)).uppercased() + String(part.dropFirst()) + } + guard !suffix.isEmpty else { + return "GPT-\(version)" + } + return "GPT-\(version) \(suffix.joined(separator: " "))" + } + + private static func breakdownValueText( + costUSD: Double?, + totalTokens: Int?, + includeUnpricedLabel: Bool = false) -> String? + { var parts: [String] = [] if let costUSD, costUSD > 0 { parts.append(UsageFormatter.usdString(costUSD)) + } else if includeUnpricedLabel { + parts.append("unpriced") } if let totalTokens, totalTokens > 0 { parts.append("\(UsageFormatter.tokenCountString(totalTokens)) tokens") diff --git a/Sources/CodexBarCLI/CLICostCommand.swift b/Sources/CodexBarCLI/CLICostCommand.swift index c3b82b0e2..fb749d619 100644 --- a/Sources/CodexBarCLI/CLICostCommand.swift +++ b/Sources/CodexBarCLI/CLICostCommand.swift @@ -162,6 +162,7 @@ extension CodexBarCLI { var sawCacheCreation = false var sawTokens = false var sawCost = false + var hasUnknownCost = false for entry in entries { if let input = entry.inputTokens { @@ -187,9 +188,19 @@ extension CodexBarCLI { if let cost = entry.costUSD { totalCost += cost sawCost = true + } else if (entry.totalTokens ?? 0) > 0 { + hasUnknownCost = true } } + let aggregateCost: Double? = if hasUnknownCost { + nil + } else if sawCost { + totalCost + } else { + snapshot.last30DaysCostUSD + } + // Prefer totals derived from daily rows; fall back to snapshot aggregates when rows omit fields. return CostTotalsPayload( totalInputTokens: sawInput ? totalInput : nil, @@ -197,9 +208,21 @@ extension CodexBarCLI { cacheReadTokens: sawCacheRead ? totalCacheRead : nil, cacheCreationTokens: sawCacheCreation ? totalCacheCreation : nil, totalTokens: sawTokens ? totalTokens : snapshot.last30DaysTokens, - totalCostUSD: sawCost ? totalCost : snapshot.last30DaysCostUSD) + totalCostUSD: aggregateCost) + } +} + +#if DEBUG +extension CodexBarCLI { + static func _makeCostPayloadForTesting( + provider: UsageProvider, + snapshot: CostUsageTokenSnapshot?, + error: Error? = nil) -> CostPayload + { + self.makeCostPayload(provider: provider, snapshot: snapshot, error: error) } } +#endif struct CostOptions: CommanderParsable { @Flag(names: [.short("v"), .long("verbose")], help: "Enable verbose logging") diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Codex.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Codex.swift index 54ce34761..c4ec06259 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Codex.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Codex.swift @@ -167,7 +167,7 @@ extension CostUsageScanner { state.currentModel = explicitModel } - let model = usage.explicitModel ?? state.currentModel ?? "gpt-5" + let model = usage.explicitModel ?? state.currentModel ?? "unknown" var deltaInput = 0 var deltaCached = 0 var deltaOutput = 0 diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index cebdcb425..f1a2698bb 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -401,6 +401,7 @@ enum CostUsageScanner { var totalTokens = 0 var totalCost: Double = 0 var costSeen = false + var hasUnknownCost = false let dayKeys = cache.days.keys.sorted().filter { CostUsageDayRange.isInRange(dayKey: $0, since: range.sinceKey, until: range.untilKey) @@ -416,6 +417,7 @@ enum CostUsageScanner { var breakdown: [CostUsageDailyReport.ModelBreakdown] = [] var dayCost: Double = 0 var dayCostSeen = false + var dayHasUnknownCost = false for model in modelNames { let packed = models[model] ?? [0, 0, 0] @@ -439,6 +441,8 @@ enum CostUsageScanner { if let cost { dayCost += cost dayCostSeen = true + } else if modelTotal > 0 || cached > 0 { + dayHasUnknownCost = true } } @@ -453,7 +457,7 @@ enum CostUsageScanner { } let dayTotal = dayInput + dayOutput - let entryCost = dayCostSeen ? dayCost : nil + let entryCost = dayCostSeen && !dayHasUnknownCost ? dayCost : nil entries.append(CostUsageDailyReport.Entry( date: day, inputTokens: dayInput, @@ -466,6 +470,9 @@ enum CostUsageScanner { totalInput += dayInput totalOutput += dayOutput totalTokens += dayTotal + if dayHasUnknownCost { + hasUnknownCost = true + } if let entryCost { totalCost += entryCost costSeen = true @@ -478,7 +485,7 @@ enum CostUsageScanner { totalInputTokens: totalInput, totalOutputTokens: totalOutput, totalTokens: totalTokens, - totalCostUSD: costSeen ? totalCost : nil) + totalCostUSD: costSeen && !hasUnknownCost ? totalCost : nil) return CostUsageDailyReport(data: entries, summary: summary) } diff --git a/Tests/CodexBarTests/CLICostTests.swift b/Tests/CodexBarTests/CLICostTests.swift index 6d14ecef7..17cedba9c 100644 --- a/Tests/CodexBarTests/CLICostTests.swift +++ b/Tests/CodexBarTests/CLICostTests.swift @@ -141,4 +141,37 @@ struct CLICostTests { #expect(!json.contains("\"gpt-5.2\"")) #expect(json.contains("\"cost\":0")) } + + @Test + func costPayloadPreservesNilAggregateCostForMixedKnownAndUnknownDays() { + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 200, + sessionCostUSD: nil, + last30DaysTokens: 300, + last30DaysCostUSD: nil, + daily: [ + CostUsageDailyReport.Entry( + date: "2025-12-20", + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + costUSD: 0.01, + modelsUsed: ["gpt-5.2-codex"], + modelBreakdowns: nil), + CostUsageDailyReport.Entry( + date: "2025-12-21", + inputTokens: 20, + outputTokens: 10, + totalTokens: 30, + costUSD: nil, + modelsUsed: ["unknown"], + modelBreakdowns: nil), + ], + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + + let payload = CodexBarCLI._makeCostPayloadForTesting(provider: .codex, snapshot: snapshot) + + #expect(payload.totals?.totalTokens == 45) + #expect(payload.totals?.totalCostUSD == nil) + } } diff --git a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift new file mode 100644 index 000000000..b1358e860 --- /dev/null +++ b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift @@ -0,0 +1,143 @@ +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@Suite +struct CostHistoryChartMenuViewTests { + @Test + @MainActor + func detailContentUsesOnlySelectedDayModels() { + let daily = [ + CostUsageDailyReport.Entry( + date: "2025-12-01", + inputTokens: 100, + outputTokens: 20, + totalTokens: 120, + costUSD: 0.12, + modelsUsed: ["gpt-5.2-codex", "gpt-5.3-codex", "unknown"], + modelBreakdowns: [ + .init(modelName: "gpt-5.2-codex", costUSD: 0.06, totalTokens: 60), + .init(modelName: "gpt-5.3-codex", costUSD: 0.04, totalTokens: 40), + .init(modelName: "unknown", costUSD: nil, totalTokens: 20), + ]), + CostUsageDailyReport.Entry( + date: "2025-12-02", + inputTokens: 60, + outputTokens: 12, + totalTokens: 72, + costUSD: 0.05, + modelsUsed: ["gpt-5.4"], + modelBreakdowns: [ + .init(modelName: "gpt-5.4", costUSD: 0.05, totalTokens: 72), + ]), + ] + + let model = CostHistoryChartMenuView.makeModel(provider: .codex, daily: daily) + let detail = CostHistoryChartMenuView.detailContent(selectedDateKey: "2025-12-02", model: model) + + #expect(detail.models.count == 1) + #expect(detail.models[0].text.contains("GPT-5.4")) + #expect(detail.models[0].text.contains("72 tokens")) + #expect(model.maxDetailLineCount == 3) + } + + @Test + @MainActor + func makeModelDoesNotReserveDetailRowsWithoutBreakdowns() { + let daily = [ + CostUsageDailyReport.Entry( + date: "2025-12-01", + inputTokens: 40, + outputTokens: 10, + totalTokens: 50, + costUSD: 0.02, + modelsUsed: ["gpt-5.2-codex"], + modelBreakdowns: nil), + ] + + let model = CostHistoryChartMenuView.makeModel(provider: .codex, daily: daily) + + #expect(model.maxDetailLineCount == 0) + } + + @Test + @MainActor + func makeModelKeepsMixedKnownAndUnknownDaysVisible() throws { + let daily = [ + CostUsageDailyReport.Entry( + date: "2025-12-01", + inputTokens: 100, + outputTokens: 20, + totalTokens: 120, + costUSD: nil, + modelsUsed: ["gpt-5.2-codex", "unknown"], + modelBreakdowns: [ + .init(modelName: "gpt-5.2-codex", costUSD: 0.08, totalTokens: 80), + .init(modelName: "unknown", costUSD: nil, totalTokens: 40), + ]), + ] + + let model = CostHistoryChartMenuView.makeModel(provider: .codex, daily: daily) + let point = try #require(model.pointsByDateKey["2025-12-01"]) + let detail = CostHistoryChartMenuView.detailContent(selectedDateKey: "2025-12-01", model: model) + + #expect(point.displayCostUSD == 0.08) + #expect(point.actualCostUSD == nil) + #expect(detail.primary.contains("$0.08")) + #expect(detail.primary.contains("partial")) + #expect(detail.primary.contains("120 tokens")) + } + + @Test + @MainActor + func makeModelKeepsUnknownOnlyDaysVisible() throws { + let daily = [ + CostUsageDailyReport.Entry( + date: "2025-12-01", + inputTokens: 100, + outputTokens: 20, + totalTokens: 120, + costUSD: nil, + modelsUsed: ["unknown"], + modelBreakdowns: [ + .init(modelName: "unknown", costUSD: nil, totalTokens: 120), + ]), + ] + + let model = CostHistoryChartMenuView.makeModel(provider: .codex, daily: daily) + let point = try #require(model.pointsByDateKey["2025-12-01"]) + let detail = CostHistoryChartMenuView.detailContent(selectedDateKey: "2025-12-01", model: model) + + #expect(point.displayCostUSD == 0) + #expect(detail.primary.contains("No priced cost data")) + #expect(detail.primary.contains("120 tokens")) + } + + @Test + @MainActor + func makeModelCapsDetailRowsForBusyDays() { + let daily = [ + CostUsageDailyReport.Entry( + date: "2025-12-01", + inputTokens: 300, + outputTokens: 60, + totalTokens: 360, + costUSD: 0.18, + modelsUsed: ["gpt-5", "gpt-5-mini", "gpt-5-pro", "gpt-5.2", "unknown"], + modelBreakdowns: [ + .init(modelName: "gpt-5", costUSD: 0.04, totalTokens: 80), + .init(modelName: "gpt-5-mini", costUSD: 0.02, totalTokens: 70), + .init(modelName: "gpt-5-pro", costUSD: 0.05, totalTokens: 60), + .init(modelName: "gpt-5.2", costUSD: 0.04, totalTokens: 80), + .init(modelName: "unknown", costUSD: nil, totalTokens: 70), + ]), + ] + + let model = CostHistoryChartMenuView.makeModel(provider: .codex, daily: daily) + let detail = CostHistoryChartMenuView.detailContent(selectedDateKey: "2025-12-01", model: model) + + #expect(model.maxDetailLineCount == 4) + #expect(detail.models.count == 4) + #expect(detail.models.last?.text == "2 more models") + } +} diff --git a/Tests/CodexBarTests/CostUsageScannerCodexCostSemanticsTests.swift b/Tests/CodexBarTests/CostUsageScannerCodexCostSemanticsTests.swift new file mode 100644 index 000000000..ff55d0e4f --- /dev/null +++ b/Tests/CodexBarTests/CostUsageScannerCodexCostSemanticsTests.swift @@ -0,0 +1,137 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite +struct CostUsageScannerCodexCostSemanticsTests { + @Test + func codexDailyReportKeepsUnknownModelsUnpriced() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2025, month: 12, day: 21) + let iso0 = env.isoString(for: day) + + let tokenCountWithoutModel: [String: Any] = [ + "type": "event_msg", + "timestamp": iso0, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 100, + "cached_input_tokens": 20, + "output_tokens": 10, + ], + ], + ], + ] + + _ = try env.writeCodexSessionFile( + day: day, + filename: "session-unknown-model.jsonl", + contents: env.jsonl([tokenCountWithoutModel])) + + 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) + + let entry = try #require(report.data.first) + #expect(entry.modelsUsed == ["unknown"]) + #expect(entry.modelBreakdowns?.contains { + $0.modelName == "unknown" && $0.costUSD == nil && $0.totalTokens == 110 + } == true) + #expect(report.summary?.totalCostUSD == nil) + } + + @Test + func codexDailyReportNilCostWhenKnownAndUnknownModelsMix() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2025, month: 12, day: 22) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let iso2 = env.isoString(for: day.addingTimeInterval(2)) + + let knownModel = "openai/gpt-5.2-codex" + let turnContext: [String: Any] = [ + "type": "turn_context", + "timestamp": iso0, + "payload": [ + "model": knownModel, + ], + ] + let knownTokenCount: [String: Any] = [ + "type": "event_msg", + "timestamp": iso1, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 100, + "cached_input_tokens": 20, + "output_tokens": 10, + ], + "model": knownModel, + ], + ], + ] + let unknownTokenCount: [String: Any] = [ + "type": "event_msg", + "timestamp": iso2, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 80, + "cached_input_tokens": 0, + "output_tokens": 8, + ], + ], + ], + ] + + _ = try env.writeCodexSessionFile( + day: day, + filename: "session-known-model.jsonl", + contents: env.jsonl([turnContext, knownTokenCount])) + _ = try env.writeCodexSessionFile( + day: day, + filename: "session-unknown-model.jsonl", + contents: env.jsonl([unknownTokenCount])) + + 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) + + let entry = try #require(report.data.first) + #expect(entry.modelsUsed == ["gpt-5.2-codex", "unknown"]) + #expect(entry.costUSD == nil) + #expect(entry.modelBreakdowns?.contains { + $0.modelName == "gpt-5.2-codex" && $0.costUSD != nil && $0.totalTokens == 110 + } == true) + #expect(entry.modelBreakdowns?.contains { + $0.modelName == "unknown" && $0.costUSD == nil && $0.totalTokens == 88 + } == true) + #expect(report.summary?.totalCostUSD == nil) + } +} From 84819c2eae76190600e4bebc8cc2f4132e70011b Mon Sep 17 00:00:00 2001 From: Bryan Font Date: Fri, 13 Mar 2026 10:01:49 -0400 Subject: [PATCH 9/9] Backfill rolling cost chart day slots --- .../CodexBar/CostHistoryChartMenuView.swift | 126 +++++++++++++--- .../CostHistoryChartMenuViewTests.swift | 141 +++++++++++++++++- 2 files changed, 237 insertions(+), 30 deletions(-) diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index 594669196..e770f05bd 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -61,7 +61,7 @@ struct CostHistoryChartMenuView: View { BarMark( x: .value("Day", point.date, unit: .day), y: .value("Cost", point.displayCostUSD)) - .foregroundStyle(model.barColor) + .foregroundStyle(point.displayCostUSD > 0 ? model.barColor : Color.clear) } if let peak = Self.peakPoint(model: model) { let peakCostUSD = peak.actualCostUSD ?? 0 @@ -182,7 +182,26 @@ struct CostHistoryChartMenuView: View { } static func makeModel(provider: UsageProvider, daily: [DailyEntry]) -> Model { + self.makeModel(provider: provider, daily: daily, now: Date()) + } + + static func makeModel(provider: UsageProvider, daily: [DailyEntry], now: Date) -> Model { let sorted = daily.sorted { lhs, rhs in lhs.date < rhs.date } + guard !sorted.isEmpty else { + let barColor = Self.barColor(for: provider) + return Model( + points: [], + pointsByDateKey: [:], + entriesByDateKey: [:], + dateKeys: [], + axisDates: [], + barColor: barColor, + peakKey: nil, + maxCostUSD: 0, + maxDetailLineCount: 0) + } + + let dayRange = Self.rollingDayKeys(endingAt: now) var entriesByKey: [String: DailyEntry] = [:] entriesByKey.reserveCapacity(sorted.count) for entry in sorted { @@ -190,40 +209,43 @@ struct CostHistoryChartMenuView: View { } var points: [Point] = [] - points.reserveCapacity(sorted.count) + points.reserveCapacity(dayRange.count) var pointsByKey: [String: Point] = [:] - pointsByKey.reserveCapacity(sorted.count) + pointsByKey.reserveCapacity(dayRange.count) var dateKeys: [(key: String, date: Date)] = [] - dateKeys.reserveCapacity(sorted.count) + dateKeys.reserveCapacity(dayRange.count) var peak: (key: String, costUSD: Double)? var maxCostUSD: Double = 0 - for entry in sorted { + for item in dayRange { + guard let entry = entriesByKey[item.key] else { continue } if let displayCostUSD = Self.displayCostUSD(for: entry), displayCostUSD > 0 { maxCostUSD = max(maxCostUSD, displayCostUSD) } } var maxDetailLineCount = 0 - for entry in sorted { - maxDetailLineCount = max(maxDetailLineCount, Self.renderedBreakdownRowCount(for: entry)) - guard let displayCostUSD = Self.displayCostUSD(for: entry) else { continue } - guard let date = self.dateFromDayKey(entry.date) else { continue } + for item in dayRange { + let entry = entriesByKey[item.key] + if let entry { + maxDetailLineCount = max(maxDetailLineCount, Self.renderedBreakdownRowCount(for: entry)) + } + let displayCostUSD = entry.flatMap(Self.displayCostUSD(for:)) ?? 0 let point = Point( - date: date, + date: item.date, displayCostUSD: displayCostUSD, - actualCostUSD: entry.costUSD, - totalTokens: entry.totalTokens) + actualCostUSD: entry?.costUSD, + totalTokens: entry?.totalTokens) points.append(point) - pointsByKey[entry.date] = point - dateKeys.append((entry.date, date)) + pointsByKey[item.key] = point + dateKeys.append((item.key, item.date)) if displayCostUSD > 0 { if let cur = peak { - if displayCostUSD > cur.costUSD { peak = (entry.date, displayCostUSD) } + if displayCostUSD > cur.costUSD { peak = (item.key, displayCostUSD) } } else { - peak = (entry.date, displayCostUSD) + peak = (item.key, displayCostUSD) } } } @@ -269,6 +291,25 @@ struct CostHistoryChartMenuView: View { return comps.date } + private static func rollingDayKeys(endingAt now: Date) -> [(key: String, date: Date)] { + var days: [(key: String, date: Date)] = [] + let calendar = Calendar.current + let end = calendar.startOfDay(for: now) + let start = calendar.date(byAdding: .day, value: -29, to: end) ?? end + var current = start + + while current <= end { + let comps = calendar.dateComponents([.year, .month, .day], from: current) + let key = String(format: "%04d-%02d-%02d", comps.year ?? 1970, comps.month ?? 1, comps.day ?? 1) + let date = calendar.date(bySettingHour: 12, minute: 0, second: 0, of: current) ?? current + days.append((key, date)) + guard let next = calendar.date(byAdding: .day, value: 1, to: current) else { break } + current = next + } + + return days + } + private static func peakPoint(model: Model) -> Point? { guard let key = model.peakKey else { return nil } return model.pointsByDateKey[key] @@ -374,11 +415,11 @@ struct CostHistoryChartMenuView: View { let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) let partial = Self.hasUnpricedModels(key: key, model: model) ? " partial" : "" - let primary = if let actualCostUSD = point.actualCostUSD, actualCostUSD > 0 { + let primary = if let actualCostUSD = point.actualCostUSD { "\(dayLabel): \(UsageFormatter.usdString(actualCostUSD))\(partial)" } else if point.displayCostUSD > 0 { "\(dayLabel): \(UsageFormatter.usdString(point.displayCostUSD))\(partial)" - } else if point.totalTokens ?? 0 > 0 { + } else if let entry = model.entriesByDateKey[key], Self.hasUsage(entry: entry) { "\(dayLabel): No priced cost data" } else { "\(dayLabel): No cost data" @@ -403,8 +444,22 @@ struct CostHistoryChartMenuView: View { return breakdown.contains { $0.costUSD == nil } } + private static func hasUsage(entry: DailyEntry) -> Bool { + if let totalTokens = entry.totalTokens, totalTokens > 0 { + return true + } + if (entry.inputTokens ?? 0) > 0 + || (entry.outputTokens ?? 0) > 0 + || (entry.cacheCreationTokens ?? 0) > 0 + || (entry.cacheReadTokens ?? 0) > 0 + { + return true + } + return false + } + private static func displayCostUSD(for entry: DailyEntry) -> Double? { - if let actualCostUSD = entry.costUSD, actualCostUSD > 0 { + if let actualCostUSD = entry.costUSD, actualCostUSD >= 0 { return actualCostUSD } guard let breakdown = entry.modelBreakdowns else { return nil } @@ -452,6 +507,7 @@ struct CostHistoryChartMenuView: View { let hidden = Array(sorted.dropFirst(visibleLimit)) if !hidden.isEmpty { + let hiddenHasPricedCost = hidden.contains { $0.costUSD != nil } let hiddenCost = hidden.reduce(0.0) { partial, item in partial + (item.costUSD ?? 0) } @@ -464,7 +520,7 @@ struct CostHistoryChartMenuView: View { text: overflowTitle, title: overflowTitle, subtitle: Self.breakdownValueText( - costUSD: hiddenCost > 0 ? hiddenCost : nil, + costUSD: hiddenHasPricedCost ? hiddenCost : nil, totalTokens: hiddenTokens > 0 ? hiddenTokens : nil), accentColor: Color(nsColor: .tertiaryLabelColor).opacity(0.55))) } @@ -479,7 +535,7 @@ struct CostHistoryChartMenuView: View { private static func detailLineText(title: String, costUSD: Double?, totalTokens: Int?) -> String { let tokensText = totalTokens.map { " · \(UsageFormatter.tokenCountString($0)) tokens" } ?? "" - if let costUSD, costUSD > 0 { + if let costUSD { return "\(title): \(UsageFormatter.usdString(costUSD))\(tokensText)" } return "\(title): unpriced\(tokensText)" @@ -515,7 +571,7 @@ struct CostHistoryChartMenuView: View { includeUnpricedLabel: Bool = false) -> String? { var parts: [String] = [] - if let costUSD, costUSD > 0 { + if let costUSD { parts.append(UsageFormatter.usdString(costUSD)) } else if includeUnpricedLabel { parts.append("unpriced") @@ -526,3 +582,29 @@ struct CostHistoryChartMenuView: View { return parts.isEmpty ? nil : parts.joined(separator: " · ") } } + +extension CostHistoryChartMenuView { + enum TestSupport { + struct DayState: Equatable { + let dayKey: String + let costUSD: Double + let hasEntry: Bool + } + + @MainActor + static func makeDayStates( + provider: UsageProvider = .codex, + daily: [DailyEntry], + now: Date) -> [DayState] + { + let model = CostHistoryChartMenuView.makeModel(provider: provider, daily: daily, now: now) + return model.dateKeys.compactMap { item -> DayState? in + guard let point = model.pointsByDateKey[item.key] else { return nil } + return DayState( + dayKey: item.key, + costUSD: point.displayCostUSD, + hasEntry: model.entriesByDateKey[item.key] != nil) + } + } + } +} diff --git a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift index b1358e860..0a72c0c5c 100644 --- a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift +++ b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import CodexBar @testable import CodexBarCore @@ -6,7 +7,8 @@ import Testing struct CostHistoryChartMenuViewTests { @Test @MainActor - func detailContentUsesOnlySelectedDayModels() { + func detailContentUsesOnlySelectedDayModels() throws { + let now = try #require(Self.date(year: 2025, month: 12, day: 2, hour: 9)) let daily = [ CostUsageDailyReport.Entry( date: "2025-12-01", @@ -32,7 +34,7 @@ struct CostHistoryChartMenuViewTests { ]), ] - let model = CostHistoryChartMenuView.makeModel(provider: .codex, daily: daily) + let model = CostHistoryChartMenuView.makeModel(provider: .codex, daily: daily, now: now) let detail = CostHistoryChartMenuView.detailContent(selectedDateKey: "2025-12-02", model: model) #expect(detail.models.count == 1) @@ -43,7 +45,8 @@ struct CostHistoryChartMenuViewTests { @Test @MainActor - func makeModelDoesNotReserveDetailRowsWithoutBreakdowns() { + func makeModelDoesNotReserveDetailRowsWithoutBreakdowns() throws { + let now = try #require(Self.date(year: 2025, month: 12, day: 1, hour: 9)) let daily = [ CostUsageDailyReport.Entry( date: "2025-12-01", @@ -55,7 +58,7 @@ struct CostHistoryChartMenuViewTests { modelBreakdowns: nil), ] - let model = CostHistoryChartMenuView.makeModel(provider: .codex, daily: daily) + let model = CostHistoryChartMenuView.makeModel(provider: .codex, daily: daily, now: now) #expect(model.maxDetailLineCount == 0) } @@ -63,6 +66,7 @@ struct CostHistoryChartMenuViewTests { @Test @MainActor func makeModelKeepsMixedKnownAndUnknownDaysVisible() throws { + let now = try #require(Self.date(year: 2025, month: 12, day: 1, hour: 9)) let daily = [ CostUsageDailyReport.Entry( date: "2025-12-01", @@ -77,7 +81,7 @@ struct CostHistoryChartMenuViewTests { ]), ] - let model = CostHistoryChartMenuView.makeModel(provider: .codex, daily: daily) + let model = CostHistoryChartMenuView.makeModel(provider: .codex, daily: daily, now: now) let point = try #require(model.pointsByDateKey["2025-12-01"]) let detail = CostHistoryChartMenuView.detailContent(selectedDateKey: "2025-12-01", model: model) @@ -91,6 +95,7 @@ struct CostHistoryChartMenuViewTests { @Test @MainActor func makeModelKeepsUnknownOnlyDaysVisible() throws { + let now = try #require(Self.date(year: 2025, month: 12, day: 1, hour: 9)) let daily = [ CostUsageDailyReport.Entry( date: "2025-12-01", @@ -104,7 +109,7 @@ struct CostHistoryChartMenuViewTests { ]), ] - let model = CostHistoryChartMenuView.makeModel(provider: .codex, daily: daily) + let model = CostHistoryChartMenuView.makeModel(provider: .codex, daily: daily, now: now) let point = try #require(model.pointsByDateKey["2025-12-01"]) let detail = CostHistoryChartMenuView.detailContent(selectedDateKey: "2025-12-01", model: model) @@ -115,7 +120,8 @@ struct CostHistoryChartMenuViewTests { @Test @MainActor - func makeModelCapsDetailRowsForBusyDays() { + func makeModelCapsDetailRowsForBusyDays() throws { + let now = try #require(Self.date(year: 2025, month: 12, day: 1, hour: 9)) let daily = [ CostUsageDailyReport.Entry( date: "2025-12-01", @@ -133,11 +139,130 @@ struct CostHistoryChartMenuViewTests { ]), ] - let model = CostHistoryChartMenuView.makeModel(provider: .codex, daily: daily) + let model = CostHistoryChartMenuView.makeModel(provider: .codex, daily: daily, now: now) let detail = CostHistoryChartMenuView.detailContent(selectedDateKey: "2025-12-01", model: model) #expect(model.maxDetailLineCount == 4) #expect(detail.models.count == 4) #expect(detail.models.last?.text == "2 more models") } + + @Test + @MainActor + func makeModelKeepsZeroCostDaysPriced() throws { + let now = try #require(Self.date(year: 2025, month: 12, day: 1, hour: 9)) + let daily = [ + CostUsageDailyReport.Entry( + date: "2025-12-01", + inputTokens: 100, + outputTokens: 20, + totalTokens: 120, + costUSD: 0, + modelsUsed: ["gpt-5.3-codex-spark"], + modelBreakdowns: [ + .init(modelName: "gpt-5.3-codex-spark", costUSD: 0, totalTokens: 120), + ]), + ] + + let model = CostHistoryChartMenuView.makeModel(provider: .codex, daily: daily, now: now) + let point = try #require(model.pointsByDateKey["2025-12-01"]) + let detail = CostHistoryChartMenuView.detailContent(selectedDateKey: "2025-12-01", model: model) + + #expect(point.displayCostUSD == 0) + #expect(point.actualCostUSD == 0) + #expect(detail.primary.contains("$0.00")) + #expect(detail.primary.contains("120 tokens")) + #expect(detail.models.first?.text.contains("$0.00") == true) + #expect(detail.models.first?.text.contains("unpriced") == false) + } + + @Test + @MainActor + func makeDayStatesBuildsRollingThirtyDayWindowEndingToday() throws { + let now = try #require(Self.date(year: 2026, month: 3, day: 12, hour: 9)) + let daily = [ + CostUsageDailyReport.Entry( + date: "2026-03-12", + inputTokens: 120, + outputTokens: 30, + totalTokens: 150, + costUSD: 0.25, + modelsUsed: ["gpt-5.4-codex"], + modelBreakdowns: nil), + ] + + let days = CostHistoryChartMenuView.TestSupport.makeDayStates(daily: daily, now: now) + + #expect(days.count == 30) + #expect(days.first?.dayKey == "2026-02-11") + #expect(days.last?.dayKey == "2026-03-12") + + let today = try #require(days.last) + #expect(today.hasEntry == true) + #expect(today.costUSD == 0.25) + + let earlierDays = days.dropLast() + #expect(earlierDays.allSatisfy { $0.hasEntry == false }) + #expect(earlierDays.allSatisfy { $0.costUSD == 0 }) + } + + @Test + @MainActor + func makeDayStatesKeepsNilAndZeroCostDaysAsEmptySlots() throws { + let now = try #require(Self.date(year: 2026, month: 3, day: 12, hour: 9)) + let daily = [ + CostUsageDailyReport.Entry( + date: "2026-03-10", + inputTokens: 100, + outputTokens: 20, + totalTokens: 120, + costUSD: nil, + modelsUsed: ["unknown"], + modelBreakdowns: nil), + CostUsageDailyReport.Entry( + date: "2026-03-11", + inputTokens: 90, + outputTokens: 10, + totalTokens: 100, + costUSD: 0, + modelsUsed: ["claude-sonnet-4-5"], + modelBreakdowns: nil), + CostUsageDailyReport.Entry( + date: "2026-03-12", + inputTokens: 70, + outputTokens: 10, + totalTokens: 80, + costUSD: 0.08, + modelsUsed: ["claude-sonnet-4-5"], + modelBreakdowns: nil), + ] + + let days = CostHistoryChartMenuView.TestSupport.makeDayStates( + provider: .claude, + daily: daily, + now: now) + + let nilCostDay = try #require(days.first { $0.dayKey == "2026-03-10" }) + #expect(nilCostDay.hasEntry == true) + #expect(nilCostDay.costUSD == 0) + + let zeroCostDay = try #require(days.first { $0.dayKey == "2026-03-11" }) + #expect(zeroCostDay.hasEntry == true) + #expect(zeroCostDay.costUSD == 0) + + let pricedDay = try #require(days.first { $0.dayKey == "2026-03-12" }) + #expect(pricedDay.hasEntry == true) + #expect(pricedDay.costUSD == 0.08) + } + + private static func date(year: Int, month: Int, day: Int, hour: Int) -> Date? { + var components = DateComponents() + components.calendar = Calendar.current + components.timeZone = TimeZone.current + components.year = year + components.month = month + components.day = day + components.hour = hour + return components.date + } }