From 9253b9330998ef29b215eb41f1e77c5e5cb74f71 Mon Sep 17 00:00:00 2001 From: thomaschow19 <29773444+thomaschow19@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:21:51 +0000 Subject: [PATCH 1/2] Fix canonical Codex long-context pricing --- CHANGELOG.md | 1 + .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsagePricing.swift | 36 +++ .../CostUsageScanner+CacheHelpers.swift | 40 +-- .../CostUsageScannerForkSplitTests.swift | 249 ++++++++++++++++++ 5 files changed, 312 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62141b59d0..7d37715935 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - CLI: stop standalone version lookup from walking past the filesystem root and hanging with unbounded memory on affected macOS versions (#2856). Thanks @Manwholikespie! - Cost store: prevent launch-time executor-assumption crashes on macOS 15 by keeping synchronous SQLite cache bridges on their validated serial queue (#2857). Thanks @Manwholikespie! +- Codex: preserve request-level long-context pricing when rescans rebuild fork-deduplicated cost rows, preventing combined daily usage from activating the >272K tier (#2858). Thanks @thomaschow19! ## 0.49.2 — 2026-08-10 diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index b901ff1ba8..1cc003407c 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "b975eb705f905b9a" + static let value = "7be7ba55bcc97463" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 1bd0c5e624..1e7d34dfaa 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -780,4 +780,40 @@ enum CostUsagePricing { modelID: model, cacheRoot: cacheRoot) } + + static func codexAggregateCostUSD( + model: String, + inputTokens: Int, + cachedInputTokens: Int, + outputTokens: Int, + cacheWriteInputTokens: Int = 0, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil) -> Double? + { + let key = self.normalizeCodexModel(model) + let modelsDevLookup = self.modelsDevLookup( + providerID: self.codexModelsDevProviderID, + model: model, + catalog: modelsDevCatalog, + cacheRoot: modelsDevCacheRoot) + ?? (model == key ? nil : self.modelsDevLookup( + providerID: self.codexModelsDevProviderID, + model: key, + catalog: modelsDevCatalog, + cacheRoot: modelsDevCacheRoot)) + // Provider-specific by design: Codex aggregate totals cannot reveal request-level long-context tiers. + let thresholdTokens = self.codex[key]?.thresholdTokens ?? modelsDevLookup?.pricing.thresholdTokens + if let thresholdTokens, max(0, inputTokens) > thresholdTokens { + return nil + } + + return self.codexCostUSD( + model: model, + inputTokens: inputTokens, + cachedInputTokens: cachedInputTokens, + outputTokens: outputTokens, + cacheWriteInputTokens: cacheWriteInputTokens, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + } } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift index d471dc018a..cf82319f94 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift @@ -1169,6 +1169,16 @@ extension CostUsageScanner { } let uniqueDays = Self.codexFileDays(rows: uniqueRows) Self.mergeFileDays(existing: &usageDays, delta: uniqueDays) + let retainedCachedRows = context.dropDeferredCodexRows + ? [] + : Self.codexRowsOutsideScanWindow(migratedCached?.codexRows, range: context.range) + let finalRows = Self.mergeCodexRows( + retainedCachedRows, + rows: uniqueRows, + sessionId: sessionId) ?? [] + let pricedRows = Self.codexRowsWithPricingMetadata( + finalRows, + priorityTurns: context.resources.priorityTurns) let modeTokens = Self.codexModeTokenMaps( rows: uniqueRows, range: context.range, @@ -1212,17 +1222,8 @@ extension CostUsageScanner { ? nil : Self.intMapOutsideScanWindow(migratedCached?.codexPriorityTokens, range: context.range), modeTokens.priority), - codexTurnIDs: context.dropDeferredCodexRows - ? Self.codexTurnIDs(rows: uniqueRows) - : Self.mergeCodexTurnIDs(migratedCached?.codexTurnIDs, rows: uniqueRows), - codexRows: Self.codexRowsWithPricingMetadata( - context.dropDeferredCodexRows - ? uniqueRows - : Self.mergeCodexRows( - migratedCached?.codexRows, - rows: uniqueRows, - sessionId: sessionId) ?? [], - priorityTurns: context.resources.priorityTurns), + codexTurnIDs: Self.codexTurnIDs(rows: pricedRows), + codexRows: pricedRows, codexTokenSnapshots: parsed.tokenSnapshots, codexTokenCheckpoints: Self.codexTokenCheckpoints(for: parsed.tokenSnapshots), codexTokenTimestampsMonotonic: Self.codexTokenTimestampsAreMonotonic(parsed.tokenSnapshots), @@ -1291,6 +1292,15 @@ extension CostUsageScanner { } } + static func codexRowsOutsideScanWindow( + _ rows: [CodexUsageRow]?, + range: CostUsageDayRange) -> [CodexUsageRow] + { + (rows ?? []).filter { + !CostUsageDayRange.isInRange(dayKey: $0.day, since: range.scanSinceKey, until: range.scanUntilKey) + } + } + static func applyFileDays(cache: inout CostUsageCache, fileDays: [String: [String: [Int]]], sign: Int) { for (day, models) in fileDays { var dayModels = cache.days[day] ?? [:] @@ -1421,16 +1431,16 @@ extension CostUsageScanner { let authoritativeCost = authoritativeCostNanosByDayModel[day]?[model].map { Double($0) / Self.costScale } - let canonicalCost = CostUsagePricing.codexCostUSD( + let canonicalCost = CostUsagePricing.codexAggregateCostUSD( model: model, inputTokens: input, cachedInputTokens: cached, outputTokens: output, modelsDevCatalog: catalogResolver.load(modelsDevCatalogLoader), modelsDevCacheRoot: modelsDevCacheRoot) - // Physical pricing rows can retain fork-copied usage after canonical ownership - // has deduplicated the day/model totals. Reject the whole row-derived price so - // Fast uplift from the same unowned rows cannot leak into the fallback cost. + // Reject row-derived pricing when physical rows exceed canonical ownership. The + // aggregate fallback cannot infer request-level long-context tiers, so it omits an + // ambiguous estimate instead of pricing the entire day/model as one request. let cost = rowCostIsTrusted ? rowCost?.totalCostUSD ?? authoritativeCost ?? canonicalCost : canonicalCost diff --git a/Tests/CodexBarTests/CostUsageScannerForkSplitTests.swift b/Tests/CodexBarTests/CostUsageScannerForkSplitTests.swift index 03342ddd5e..b6076bddff 100644 --- a/Tests/CodexBarTests/CostUsageScannerForkSplitTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerForkSplitTests.swift @@ -71,6 +71,146 @@ struct CostUsageScannerForkSplitTests { #expect(breakdown.priorityTokens == 110) } + @Test + func `window expansion replaces stale fork rows before pricing combined short requests`() throws { + let fixture = try self.makePricingFixture( + parent: (input: 200_000, cached: 20000, output: 1000), + child: (input: 200_000, cached: 20000, output: 1000)) + defer { fixture.environment.cleanup() } + + let inflatedCache = try self.cacheByCopyingParentRowsIntoChild(fixture.cache) + let canonicalBeforeRescan = try #require(inflatedCache.days[fixture.dayKey]?[fixture.model]) + let inflatedRowTokens = inflatedCache.files.values + .flatMap { $0.codexRows ?? [] } + .reduce(0) { $0 + $1.input + $1.output } + #expect(inflatedRowTokens > canonicalBeforeRescan[0] + canonicalBeforeRescan[2]) + CostUsageStoreAccess.replace(cacheRoot: fixture.environment.cacheRoot, cache: inflatedCache) + + let earlierDay = try #require(fixture.range.calendar.date(byAdding: .day, value: -3, to: fixture.day)) + let expandedReport = CostUsageScanner.loadDailyReport( + provider: .codex, + since: earlierDay, + until: fixture.day, + now: fixture.day.addingTimeInterval(10), + options: fixture.options) + let expandedRange = CostUsageScanner.CostUsageDayRange( + since: earlierDay, + until: fixture.day, + calendar: fixture.range.calendar) + let repairedCache = CostUsageStoreAccess.read( + cacheRoot: fixture.environment.cacheRoot, + calendar: fixture.range.calendar) + + let canonical = try #require(repairedCache.days[fixture.dayKey]?[fixture.model]) + #expect(canonical == [400_000, 40000, 2000]) + let repairedRows = repairedCache.files.values.flatMap { $0.codexRows ?? [] } + #expect(repairedRows.reduce(0) { $0 + $1.input } == canonical[0]) + #expect(repairedRows.reduce(0) { $0 + $1.cached } == canonical[1]) + #expect(repairedRows.reduce(0) { $0 + $1.output } == canonical[2]) + let repairedChild = try #require(repairedCache.files.first { + $0.value.sessionId == "child-session" + }?.value) + #expect(repairedChild.codexTurnIDs == ["child-turn"]) + #expect(repairedChild.codexRows?.map(\.turnID) == ["child-turn"]) + + let parentCost = try #require(CostUsagePricing.codexCostUSD( + model: fixture.model, + inputTokens: 200_000, + cachedInputTokens: 20000, + outputTokens: 1000)) + let childCost = try #require(CostUsagePricing.codexCostUSD( + model: fixture.model, + inputTokens: 200_000, + cachedInputTokens: 20000, + outputTokens: 1000)) + let expectedCost = parentCost + childCost + let incorrectAggregateCost = try #require(CostUsagePricing.codexCostUSD( + model: fixture.model, + inputTokens: canonical[0], + cachedInputTokens: canonical[1], + outputTokens: canonical[2])) + #expect(abs(expectedCost - incorrectAggregateCost) > 1e-12) + #expect(abs((expandedReport.summary?.totalCostUSD ?? 0) - expectedCost) < 1e-12) + + let sessions = CostUsageScanner.buildCodexSessionBreakdownsFromCache( + cache: repairedCache, + range: expandedRange) + #expect(sessions.count == 2) + #expect(abs((sessions.first { $0.sessionID == "parent-session" }?.costUSD ?? 0) - parentCost) < 1e-12) + #expect(abs((sessions.first { $0.sessionID == "child-session" }?.costUSD ?? 0) - childCost) < 1e-12) + let sessionCost = sessions.compactMap(\.costUSD).reduce(0, +) + #expect(abs(sessionCost - expectedCost) < 1e-12) + + let projects = CostUsageScanner.buildCodexProjectBreakdownsFromCache( + cache: repairedCache, + range: expandedRange) + #expect(projects.count == 1) + #expect(abs((projects.first?.totalCostUSD ?? 0) - expectedCost) < 1e-12) + } + + @Test + func `trusted fork rows tier only the genuine long context request`() throws { + let fixture = try self.makePricingFixture( + parent: (input: 300_000, cached: 30000, output: 1000), + child: (input: 100_000, cached: 10000, output: 1000)) + defer { fixture.environment.cleanup() } + + let report = CostUsageScanner.buildCodexReportFromCache(cache: fixture.cache, range: fixture.range) + let longRequestCost = try #require(CostUsagePricing.codexCostUSD( + model: fixture.model, + inputTokens: 300_000, + cachedInputTokens: 30000, + outputTokens: 1000)) + let shortRequestCost = try #require(CostUsagePricing.codexCostUSD( + model: fixture.model, + inputTokens: 100_000, + cachedInputTokens: 10000, + outputTokens: 1000)) + let canonical = try #require(fixture.cache.days[fixture.dayKey]?[fixture.model]) + let incorrectlyTieredAggregate = try #require(CostUsagePricing.codexCostUSD( + model: fixture.model, + inputTokens: canonical[0], + cachedInputTokens: canonical[1], + outputTokens: canonical[2])) + let expectedCost = longRequestCost + shortRequestCost + + #expect(abs((report.summary?.totalCostUSD ?? 0) - expectedCost) < 1e-12) + #expect(abs(expectedCost - incorrectlyTieredAggregate) > 1e-12) + let sessions = CostUsageScanner.buildCodexSessionBreakdownsFromCache( + cache: fixture.cache, + range: fixture.range) + #expect(abs((sessions.first { $0.sessionID == "parent-session" }?.costUSD ?? 0) - longRequestCost) < 1e-12) + #expect(abs((sessions.first { $0.sessionID == "child-session" }?.costUSD ?? 0) - shortRequestCost) < 1e-12) + } + + @Test + func `codex report omits an ambiguous long context aggregate fallback`() throws { + let fixture = try self.makePricingFixture( + parent: (input: 200_000, cached: 20000, output: 1000), + child: (input: 200_000, cached: 20000, output: 1000)) + defer { fixture.environment.cleanup() } + + let inflatedCache = try self.cacheByCopyingParentRowsIntoChild(fixture.cache) + let canonical = try #require(inflatedCache.days[fixture.dayKey]?[fixture.model]) + #expect(CostUsagePricing.codexAggregateCostUSD( + model: fixture.model, + inputTokens: canonical[0], + cachedInputTokens: canonical[1], + outputTokens: canonical[2]) == nil) + #expect(CostUsagePricing.codexCostUSD( + model: fixture.model, + inputTokens: canonical[0], + cachedInputTokens: canonical[1], + outputTokens: canonical[2]) != nil) + + let report = CostUsageScanner.buildCodexReportFromCache(cache: inflatedCache, range: fixture.range) + let breakdown = try #require(report.data.first?.modelBreakdowns?.first) + #expect(breakdown.costUSD == nil) + #expect(breakdown.standardCostUSD == nil) + #expect(breakdown.priorityCostUSD == nil) + #expect(report.summary?.totalCostUSD == nil) + } + private struct Fixture { let environment: CostUsageTestEnvironment let range: CostUsageScanner.CostUsageDayRange @@ -79,6 +219,16 @@ struct CostUsageScannerForkSplitTests { let cache: CostUsageCache } + private struct PricingFixture { + let environment: CostUsageTestEnvironment + let day: Date + let range: CostUsageScanner.CostUsageDayRange + let dayKey: String + let model: String + let options: CostUsageScanner.Options + let cache: CostUsageCache + } + private func makeFixture() throws -> Fixture { let env = try CostUsageTestEnvironment() let day = try env.makeLocalNoon(year: 2026, month: 8, day: 6) @@ -157,6 +307,105 @@ struct CostUsageScannerForkSplitTests { cache: CostUsageStoreAccess.read(cacheRoot: env.cacheRoot, calendar: range.calendar)) } + private func makePricingFixture( + parent: (input: Int, cached: Int, output: Int), + child: (input: Int, cached: Int, output: Int)) throws -> PricingFixture + { + let env = try CostUsageTestEnvironment() + let day = try env.makeLocalNoon(year: 2026, month: 8, day: 6) + let parentTimestamp = env.isoString(for: day) + let parentUsageTimestamp = env.isoString(for: day.addingTimeInterval(1)) + let forkTimestamp = env.isoString(for: day.addingTimeInterval(2)) + let childUsageTimestamp = env.isoString(for: day.addingTimeInterval(3)) + let model = "gpt-5.5" + + _ = try env.writeCodexSessionFile( + day: day, + filename: "a-parent.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "timestamp": parentTimestamp, + "payload": ["id": "parent-session", "timestamp": parentTimestamp], + ], + ["type": "turn_context", "timestamp": parentTimestamp, "payload": ["model": model]], + [ + "type": "event_msg", + "timestamp": parentUsageTimestamp, + "payload": ["type": "task_started", "turn_id": "parent-turn"], + ], + self.totalTokenCount( + timestamp: parentUsageTimestamp, + input: parent.input, + cached: parent.cached, + output: parent.output), + ])) + _ = try env.writeCodexSessionFile( + day: day, + filename: "z-child.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "timestamp": forkTimestamp, + "payload": [ + "id": "child-session", + "forked_from_id": "parent-session", + "timestamp": forkTimestamp, + ], + ], + ["type": "turn_context", "timestamp": forkTimestamp, "payload": ["model": model]], + [ + "type": "event_msg", + "timestamp": childUsageTimestamp, + "payload": ["type": "task_started", "turn_id": "child-turn"], + ], + self.totalTokenCount( + timestamp: childUsageTimestamp, + input: parent.input + child.input, + cached: parent.cached + child.cached, + output: parent.output + child.output), + ])) + + var initialOptions = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite"), + forceRescan: true, + preferNewestCodexSessionsFirst: false) + initialOptions.refreshMinIntervalSeconds = 0 + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: initialOptions) + + var rescanOptions = initialOptions + rescanOptions.forceRescan = false + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) + return PricingFixture( + environment: env, + day: day, + range: range, + dayKey: range.sinceKey, + model: model, + options: rescanOptions, + cache: CostUsageStoreAccess.read(cacheRoot: env.cacheRoot, calendar: range.calendar)) + } + + private func cacheByCopyingParentRowsIntoChild(_ original: CostUsageCache) throws -> CostUsageCache { + var cache = original + let parent = try #require(cache.files.first { $0.value.sessionId == "parent-session" }) + let child = try #require(cache.files.first { $0.value.sessionId == "child-session" }) + let copiedParentRows = try #require(parent.value.codexRows) + var inflatedChild = child.value + inflatedChild.codexRows = (inflatedChild.codexRows ?? []) + copiedParentRows + inflatedChild.codexTurnIDs = Array(Set((inflatedChild.codexTurnIDs ?? []) + (parent.value.codexTurnIDs ?? []))) + .sorted() + cache.files[child.key] = inflatedChild + return cache + } + private func totalTokenCount(timestamp: String, input: Int, cached: Int, output: Int) -> [String: Any] { [ "type": "event_msg", From c7972ecd20b0df050ed06e9c556273658832c7e6 Mon Sep 17 00:00:00 2001 From: thomaschow19 <29773444+thomaschow19@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:35:08 +0000 Subject: [PATCH 2/2] Propagate incomplete Codex cost estimates --- Sources/CodexBarCLI/CLICostCommand.swift | 75 ++++++++++++++--- Sources/CodexBarCore/CostUsageFetcher.swift | 41 +++++++-- Sources/CodexBarCore/CostUsageModels.swift | 70 ++++++++++++---- .../Generated/CodexParserHash.generated.swift | 2 +- .../CostUsageScanner+CacheHelpers.swift | 15 ++-- .../CostUsage/CostUsageScanner+Projects.swift | 13 +-- .../Vendored/CostUsage/CostUsageScanner.swift | 2 +- Tests/CodexBarTests/CLICostTests.swift | 79 +++++++++++++++++ .../CostUsageDailyReportMergeTests.swift | 65 ++++++++++++++ .../CodexBarTests/CostUsageFetcherTests.swift | 49 +++++++++++ .../CostUsageScannerForkSplitTests.swift | 84 +++++++++++++++++++ .../CostUsageWindowSummaryTests.swift | 21 +++++ .../ProviderArchitectureGatekeeperTests.swift | 36 ++++---- 13 files changed, 488 insertions(+), 64 deletions(-) diff --git a/Sources/CodexBarCLI/CLICostCommand.swift b/Sources/CodexBarCLI/CLICostCommand.swift index 9faa15b057..35f0232197 100644 --- a/Sources/CodexBarCLI/CLICostCommand.swift +++ b/Sources/CodexBarCLI/CLICostCommand.swift @@ -144,14 +144,20 @@ extension CodexBarCLI { return Self.renderProjectCostText(header: header, snapshot: snapshot) } - let todayCost = snapshot.sessionCostUSD - .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" let todayTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) } + let todayCost = Self.renderCostValue( + snapshot.sessionCostUSD, + tokens: snapshot.sessionTokens, + currencyCode: snapshot.currencyCode, + describeIncomplete: provider == .codex) let todayLine = todayTokens.map { "Today: \(todayCost) · \($0) tokens" } ?? "Today: \(todayCost)" - let monthCost = snapshot.last30DaysCostUSD - .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" let monthTokens = snapshot.last30DaysTokens.map { UsageFormatter.tokenCountString($0) } + let monthCost = Self.renderCostValue( + snapshot.last30DaysCostUSD, + tokens: snapshot.last30DaysTokens, + currencyCode: snapshot.currencyCode, + describeIncomplete: provider == .codex) let historyLabel = snapshot.historyLabel ?? (snapshot.historyDays == 1 ? "Today" : "Last \(snapshot.historyDays) days") let monthLine = monthTokens.map { @@ -181,8 +187,11 @@ extension CodexBarCLI { return lines.joined(separator: "\n") } for project in snapshot.projects { - let cost = project.totalCostUSD - .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" + let cost = Self.renderCostValue( + project.totalCostUSD, + tokens: project.totalTokens, + currencyCode: snapshot.currencyCode, + describeIncomplete: true) let tokens = project.totalTokens.map { UsageFormatter.tokenCountString($0) } let summary = tokens.map { "\(cost) · \($0) tokens" } ?? cost lines.append("\(project.name): \(summary)") @@ -190,8 +199,11 @@ extension CodexBarCLI { lines.append(" \(path)") } for source in project.sources { - let sourceCost = source.totalCostUSD - .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" + let sourceCost = Self.renderCostValue( + source.totalCostUSD, + tokens: source.totalTokens, + currencyCode: snapshot.currencyCode, + describeIncomplete: true) let sourceTokens = source.totalTokens.map { UsageFormatter.tokenCountString($0) } let sourceSummary = sourceTokens.map { "\(sourceCost) · \($0) tokens" } ?? sourceCost lines.append(" - \(source.name): \(sourceSummary)") @@ -215,6 +227,21 @@ extension CodexBarCLI { return "\u{001B}[1;36m\(header)\u{001B}[0m" } + private static func renderCostValue( + _ costUSD: Double?, + tokens: Int?, + currencyCode: String, + describeIncomplete: Bool) -> String + { + if let costUSD { + return UsageFormatter.currencyString(costUSD, currencyCode: currencyCode) + } + if describeIncomplete, (tokens ?? 0) > 0 { + return "Unavailable (incomplete pricing data)" + } + return "—" + } + static func costProviders(from selection: ProviderSelection) -> [UsageProvider] { selection.asList.filter { Self.costSupportedProviders.contains($0) } } @@ -261,7 +288,10 @@ extension CodexBarCLI { meteredCostUSD: snapshot?.meteredCostUSD, daily: daily, projects: projects, - totals: snapshot.flatMap(Self.costTotals(from:)), + // Provider-specific by design: Codex omits totals when request-tier pricing is incomplete. + totals: snapshot.flatMap { + Self.costTotals(from: $0, requireCompleteCosts: provider == .codex) + }, error: error.map { Self.makeErrorPayload($0) }) } @@ -287,7 +317,10 @@ extension CodexBarCLI { totalTokens: breakdown.totalTokens) } - private static func costTotals(from snapshot: CostUsageTokenSnapshot) -> CostTotalsPayload? { + private static func costTotals( + from snapshot: CostUsageTokenSnapshot, + requireCompleteCosts: Bool) -> CostTotalsPayload? + { let entries = snapshot.daily guard !entries.isEmpty else { guard snapshot.last30DaysTokens != nil || snapshot.last30DaysCostUSD != nil else { return nil } @@ -312,6 +345,7 @@ extension CodexBarCLI { var sawCacheCreation = false var sawTokens = false var sawCost = false + var hasUnpricedUsage = false for entry in entries { if let input = entry.inputTokens { @@ -337,9 +371,19 @@ extension CodexBarCLI { if let cost = entry.costUSD { totalCost += cost sawCost = true + } else if requireCompleteCosts, Self.hasCostBearingUsage(entry) { + hasUnpricedUsage = true } } + let completeCostUSD: Double? = if requireCompleteCosts, hasUnpricedUsage { + 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, @@ -347,7 +391,16 @@ extension CodexBarCLI { cacheReadTokens: sawCacheRead ? totalCacheRead : nil, cacheCreationTokens: sawCacheCreation ? totalCacheCreation : nil, totalTokens: sawTokens ? totalTokens : snapshot.last30DaysTokens, - totalCostUSD: sawCost ? totalCost : snapshot.last30DaysCostUSD) + totalCostUSD: completeCostUSD) + } + + private static func hasCostBearingUsage(_ entry: CostUsageDailyReport.Entry) -> Bool { + max( + entry.totalTokens ?? 0, + (entry.inputTokens ?? 0) + + (entry.cacheReadTokens ?? 0) + + (entry.cacheCreationTokens ?? 0) + + (entry.outputTokens ?? 0)) > 0 } private static func decodeCostHistoryDays(from values: ParsedValues) -> Int { diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index f459c144ff..bba6de8f3a 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -467,12 +467,14 @@ public struct CostUsageFetcher: Sendable { retryUnknownPricing: false) } + // Provider-specific by design: Codex must propagate unknown request-tier costs through every aggregate. return Self.tokenSnapshot( from: scanResult.daily, now: now, historyDays: clampedHistoryDays, calendar: scanOptions.calendar, historyCoverageIsEstablished: scanResult.historyCoverageIsEstablished, + requireCompleteCosts: provider == .codex, projects: scanResult.projects, sessions: scanResult.sessions, updatedAt: scanResult.staleSnapshotUpdatedAt) @@ -576,8 +578,10 @@ public struct CostUsageFetcher: Sendable { try checkCancellation() if provider == .codex { piDaily = piReport + daily = CostUsageDailyReport.mergedRequiringCompleteCosts([daily, piReport]) + } else { + daily = CostUsageDailyReport.merged([daily, piReport]) } - daily = CostUsageDailyReport.merged([daily, piReport]) } if provider == .codex { projects = Self.mergedProjectBreakdowns( @@ -889,11 +893,12 @@ public struct CostUsageFetcher: Sendable { // rescan on the strength of another source's scan. return CachedCodexTokenSnapshotResult( snapshot: Self.tokenSnapshot( - from: CostUsageDailyReport.merged(reports), + from: CostUsageDailyReport.mergedRequiringCompleteCosts(reports), now: now, historyDays: clampedHistoryDays, calendar: options.calendar, historyCoverageIsEstablished: Self.codexHistoryCoverageIsEstablished(options: options), + requireCompleteCosts: true, projects: Self.mergedProjectBreakdowns(projects), sessions: sessions, updatedAt: scanTimes.min()), @@ -1038,6 +1043,7 @@ public struct CostUsageFetcher: Sendable { useCurrentLocalDayForSession: Bool = true, calendar: Calendar = .current, historyCoverageIsEstablished: Bool = true, + requireCompleteCosts: Bool = false, meteredCostUSD: Double? = nil, credentialScopeFingerprint: String? = nil, historyLabel: String? = nil, @@ -1063,10 +1069,16 @@ public struct CostUsageFetcher: Sendable { } else { nil } - // Prefer summary totals when present; fall back to summing daily entries. - let totalFromSummary = daily.summary?.totalCostUSD + // Prefer summary totals when present; fall back to summing daily entries. Codex uses a + // strict policy because a missing request-tier estimate makes every enclosing total incomplete. + let costsAreComplete = !requireCompleteCosts || daily.data.allSatisfy { entry in + entry.costUSD != nil || !Self.hasCostBearingUsage(entry) + } + let totalFromSummary = costsAreComplete ? daily.summary?.totalCostUSD : nil let totalFromEntries = daily.data.compactMap(\.costUSD).reduce(0, +) - let last30DaysCostUSD = totalFromSummary ?? (totalFromEntries > 0 ? totalFromEntries : nil) + let last30DaysCostUSD = costsAreComplete + ? totalFromSummary ?? (totalFromEntries > 0 ? totalFromEntries : nil) + : nil let totalTokensFromSummary = daily.summary?.totalTokens let totalTokensFromEntries = daily.data.compactMap(\.totalTokens).reduce(0, +) let last30DaysTokens = totalTokensFromSummary ?? (totalTokensFromEntries > 0 ? totalTokensFromEntries : nil) @@ -1087,6 +1099,16 @@ public struct CostUsageFetcher: Sendable { updatedAt: updatedAt ?? now) } + private static func hasCostBearingUsage(_ entry: CostUsageDailyReport.Entry) -> Bool { + let explicitOrDerivedTotal = max( + entry.totalTokens ?? 0, + (entry.inputTokens ?? 0) + + (entry.cacheReadTokens ?? 0) + + (entry.cacheCreationTokens ?? 0) + + (entry.outputTokens ?? 0)) + return explicitOrDerivedTotal > 0 + } + package static func resolvedCodexScanDurationPerRefresh( provider: UsageProvider, bypassScannerDebounce: Bool, @@ -1175,7 +1197,7 @@ public struct CostUsageFetcher: Sendable { } } return dailyByPath.map { key, reports in - let merged = CostUsageDailyReport.merged(reports) + let merged = CostUsageDailyReport.mergedRequiringCompleteCosts(reports) return CostUsageProjectBreakdown( name: namesByPath[key] ?? CostUsageProjectBreakdown.unknownProjectName, path: key.isEmpty ? nil : key, @@ -1207,7 +1229,7 @@ public struct CostUsageFetcher: Sendable { sourceNamesByPath: [String: String]) -> [CostUsageProjectSourceBreakdown] { sourceDailyByPath.map { key, reports in - let merged = CostUsageDailyReport.merged(reports) + let merged = CostUsageDailyReport.mergedRequiringCompleteCosts(reports) return CostUsageProjectSourceBreakdown( name: sourceNamesByPath[key] ?? CostUsageProjectBreakdown.unknownProjectName, path: key.isEmpty ? nil : key, @@ -1236,6 +1258,7 @@ public struct CostUsageFetcher: Sendable { var sawTotalTokens = false var costUSD: Double = 0 var sawCost = false + var hasUnpricedUsage = false mutating func add(_ breakdown: CostUsageDailyReport.ModelBreakdown) { if let totalTokens = breakdown.totalTokens { @@ -1245,13 +1268,15 @@ public struct CostUsageFetcher: Sendable { if let costUSD = breakdown.costUSD { self.costUSD += costUSD self.sawCost = true + } else if (breakdown.totalTokens ?? 0) > 0 { + self.hasUnpricedUsage = true } } func build(modelName: String) -> CostUsageDailyReport.ModelBreakdown { CostUsageDailyReport.ModelBreakdown( modelName: modelName, - costUSD: self.sawCost ? self.costUSD : nil, + costUSD: self.sawCost && !self.hasUnpricedUsage ? self.costUSD : nil, totalTokens: self.sawTotalTokens ? self.totalTokens : nil) } } diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index 322f7d59f5..99d3c1e4c4 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -153,12 +153,20 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { return dayKey >= startKey && dayKey <= endKey } let costs = entries.compactMap(\.costUSD) + let hasUnpricedUsage = entries.contains { entry in + entry.costUSD == nil && max( + entry.totalTokens ?? 0, + (entry.inputTokens ?? 0) + + (entry.cacheReadTokens ?? 0) + + (entry.cacheCreationTokens ?? 0) + + (entry.outputTokens ?? 0)) > 0 + } let tokens = entries.compactMap(\.totalTokens) let requests = entries.compactMap(\.requestCount) return CostUsageWindowSummary( days: days, totalTokens: tokens.isEmpty ? nil : tokens.reduce(0, +), - totalCostUSD: costs.isEmpty ? nil : costs.reduce(0, +), + totalCostUSD: costs.isEmpty || hasUnpricedUsage ? nil : costs.reduce(0, +), totalRequests: requests.isEmpty ? nil : requests.reduce(0, +), entryCount: entries.count) } @@ -548,6 +556,7 @@ extension CostUsageDailyReport { var sawTotalTokens = false var costUSD: Double = 0 var sawCost = false + var hasUnpricedUsage = false var standardCostUSD: Double = 0 var sawStandardCost = false var priorityCostUSD: Double = 0 @@ -557,7 +566,7 @@ extension CostUsageDailyReport { var priorityTokens: Int = 0 var sawPriorityTokens = false - mutating func add(_ breakdown: ModelBreakdown) { + mutating func add(_ breakdown: ModelBreakdown, requireCompleteCosts: Bool) { if let totalTokens = breakdown.totalTokens { self.totalTokens += totalTokens self.sawTotalTokens = true @@ -565,6 +574,12 @@ extension CostUsageDailyReport { if let costUSD = breakdown.costUSD { self.costUSD += costUSD self.sawCost = true + } else if requireCompleteCosts, + max( + breakdown.totalTokens ?? 0, + (breakdown.standardTokens ?? 0) + (breakdown.priorityTokens ?? 0)) > 0 + { + self.hasUnpricedUsage = true } if let standardCostUSD = breakdown.standardCostUSD { self.standardCostUSD += standardCostUSD @@ -587,10 +602,10 @@ extension CostUsageDailyReport { func build(modelName: String) -> ModelBreakdown { ModelBreakdown( modelName: modelName, - costUSD: self.sawCost ? self.costUSD : nil, + costUSD: self.sawCost && !self.hasUnpricedUsage ? self.costUSD : nil, totalTokens: self.sawTotalTokens ? self.totalTokens : nil, - standardCostUSD: self.sawStandardCost ? self.standardCostUSD : nil, - priorityCostUSD: self.sawPriorityCost ? self.priorityCostUSD : nil, + standardCostUSD: self.sawStandardCost && !self.hasUnpricedUsage ? self.standardCostUSD : nil, + priorityCostUSD: self.sawPriorityCost && !self.hasUnpricedUsage ? self.priorityCostUSD : nil, standardTokens: self.sawStandardTokens ? self.standardTokens : nil, priorityTokens: self.sawPriorityTokens ? self.priorityTokens : nil) } @@ -610,10 +625,11 @@ extension CostUsageDailyReport { var derivedTotalTokensWithoutExplicitTotal: Int = 0 var costUSD: Double = 0 var sawCost = false + var hasUnpricedUsage = false var modelsUsed: Set = [] var breakdowns: [String: BreakdownAccumulator] = [:] - mutating func add(_ entry: Entry) { + mutating func add(_ entry: Entry, requireCompleteCosts: Bool) { let entryDerivedTotalTokens = (entry.inputTokens ?? 0) + (entry.cacheReadTokens ?? 0) + (entry.cacheCreationTokens ?? 0) @@ -643,6 +659,10 @@ extension CostUsageDailyReport { if let costUSD = entry.costUSD { self.costUSD += costUSD self.sawCost = true + } else if requireCompleteCosts, + max(entry.totalTokens ?? 0, entryDerivedTotalTokens) > 0 + { + self.hasUnpricedUsage = true } if let modelsUsed = entry.modelsUsed { self.modelsUsed.formUnion(modelsUsed) @@ -650,7 +670,7 @@ extension CostUsageDailyReport { if let modelBreakdowns = entry.modelBreakdowns { for breakdown in modelBreakdowns { var accumulator = self.breakdowns[breakdown.modelName] ?? BreakdownAccumulator() - accumulator.add(breakdown) + accumulator.add(breakdown, requireCompleteCosts: requireCompleteCosts) self.breakdowns[breakdown.modelName] = accumulator self.modelsUsed.insert(breakdown.modelName) } @@ -685,7 +705,7 @@ extension CostUsageDailyReport { cacheReadTokens: self.sawCacheReadTokens ? self.cacheReadTokens : nil, cacheCreationTokens: self.sawCacheCreationTokens ? self.cacheCreationTokens : nil, totalTokens: totalTokens, - costUSD: self.sawCost ? self.costUSD : nil, + costUSD: self.sawCost && !self.hasUnpricedUsage ? self.costUSD : nil, modelsUsed: modelsUsed, modelBreakdowns: modelBreakdowns) } @@ -696,17 +716,33 @@ extension CostUsageDailyReport { } public static func merged(_ reports: [CostUsageDailyReport]) -> CostUsageDailyReport { - let entries = self.mergedEntries(from: reports) + self.merged(reports, requireCompleteCosts: false) + } + + static func mergedRequiringCompleteCosts(_ reports: [CostUsageDailyReport]) -> CostUsageDailyReport { + self.merged(reports, requireCompleteCosts: true) + } + + private static func merged( + _ reports: [CostUsageDailyReport], + requireCompleteCosts: Bool) -> CostUsageDailyReport + { + let entries = self.mergedEntries(from: reports, requireCompleteCosts: requireCompleteCosts) guard !entries.isEmpty else { return CostUsageDailyReport(data: [], summary: nil) } - return CostUsageDailyReport(data: entries, summary: self.mergedSummary(from: entries)) + return CostUsageDailyReport( + data: entries, + summary: self.mergedSummary(from: entries, requireCompleteCosts: requireCompleteCosts)) } - private static func mergedEntries(from reports: [CostUsageDailyReport]) -> [Entry] { + private static func mergedEntries( + from reports: [CostUsageDailyReport], + requireCompleteCosts: Bool) -> [Entry] + { var dayAccumulators: [String: EntryAccumulator] = [:] for report in reports { for entry in report.data { var accumulator = dayAccumulators[entry.date] ?? EntryAccumulator() - accumulator.add(entry) + accumulator.add(entry, requireCompleteCosts: requireCompleteCosts) dayAccumulators[entry.date] = accumulator } } @@ -719,7 +755,10 @@ extension CostUsageDailyReport { } } - private static func mergedSummary(from entries: [Entry]) -> Summary { + private static func mergedSummary( + from entries: [Entry], + requireCompleteCosts: Bool) -> Summary + { var totalInputTokens = 0 var sawTotalInputTokens = false var totalOutputTokens = 0 @@ -732,6 +771,7 @@ extension CostUsageDailyReport { var sawTotalTokens = false var totalCostUSD = 0.0 var sawTotalCostUSD = false + var hasUnpricedUsage = false for entry in entries { if let inputTokens = entry.inputTokens { @@ -757,6 +797,8 @@ extension CostUsageDailyReport { if let costUSD = entry.costUSD { totalCostUSD += costUSD sawTotalCostUSD = true + } else if requireCompleteCosts, (entry.totalTokens ?? 0) > 0 { + hasUnpricedUsage = true } } @@ -766,7 +808,7 @@ extension CostUsageDailyReport { cacheReadTokens: sawTotalCacheReadTokens ? totalCacheReadTokens : nil, cacheCreationTokens: sawTotalCacheCreationTokens ? totalCacheCreationTokens : nil, totalTokens: sawTotalTokens ? totalTokens : nil, - totalCostUSD: sawTotalCostUSD ? totalCostUSD : nil) + totalCostUSD: sawTotalCostUSD && !hasUnpricedUsage ? totalCostUSD : nil) } private static func sortedModelBreakdowns(_ breakdowns: [ModelBreakdown]) -> [ModelBreakdown] { diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 1cc003407c..e4e0a74ede 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "7be7ba55bcc97463" + static let value = "d9593f87e36eca01" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift index cf82319f94..ef9c4e25c0 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift @@ -1384,7 +1384,8 @@ extension CostUsageScanner { } var entries: [CostUsageDailyReport.Entry] = [] var (totalInput, totalCacheRead, totalOutput, totalTokens) = (0, 0, 0, 0) - var (totalCost, costSeen) = (0.0, false) + var totalCost = 0.0 + var totalCostIsComplete = true let dayKeys = self.codexReportDayKeys(cache: reportCache, range: range) let authoritativeCostNanosByDayModel = self.codexCostNanosByDayModel(cache: reportCache, range: range) @@ -1408,7 +1409,7 @@ extension CostUsageScanner { var dayOutput = 0 var breakdown: [CostUsageDailyReport.ModelBreakdown] = [] var dayCost: Double = 0 - var dayCostSeen = false + var dayCostIsComplete = true for model in modelNames { let packed = models[model] ?? [0, 0, 0] @@ -1456,12 +1457,13 @@ extension CostUsageScanner { priorityTokens: hasModeSplit ? rowCost?.optionalPriorityTokens : nil)) if let cost { dayCost += cost - dayCostSeen = true + } else if input > 0 || cached > 0 || output > 0 { + dayCostIsComplete = false } } let dayTotal = dayInput + dayOutput - let entryCost = dayCostSeen ? dayCost : nil + let entryCost = dayCostIsComplete ? dayCost : nil entries.append(CostUsageDailyReport.Entry( date: day, inputTokens: dayInput, @@ -1478,7 +1480,8 @@ extension CostUsageScanner { totalTokens += dayTotal if let entryCost { totalCost += entryCost - costSeen = true + } else if dayTotal > 0 { + totalCostIsComplete = false } } @@ -1489,7 +1492,7 @@ extension CostUsageScanner { totalOutputTokens: totalOutput, cacheReadTokens: totalCacheRead > 0 ? totalCacheRead : nil, totalTokens: totalTokens, - totalCostUSD: costSeen ? totalCost : nil) + totalCostUSD: totalCostIsComplete ? totalCost : nil) return CostUsageDailyReport(data: entries, summary: summary) } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift index 5e79e86c07..7b4d0032f1 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift @@ -118,7 +118,7 @@ extension CostUsageScanner { } return accumulatorsByProjectPath.map { projectPath, accumulator in - let merged = CostUsageDailyReport.merged(accumulator.reports) + let merged = CostUsageDailyReport.mergedRequiringCompleteCosts(accumulator.reports) let resolvedPath = projectPath.isEmpty ? nil : projectPath return CostUsageProjectBreakdown( name: Self.codexProjectName(path: resolvedPath), @@ -164,7 +164,7 @@ extension CostUsageScanner { from reportsBySourcePath: [String: [CostUsageDailyReport]]) -> [CostUsageProjectSourceBreakdown] { reportsBySourcePath.map { sourcePath, reports in - let merged = CostUsageDailyReport.merged(reports) + let merged = CostUsageDailyReport.mergedRequiringCompleteCosts(reports) let resolvedPath = sourcePath.isEmpty ? nil : sourcePath return CostUsageProjectSourceBreakdown( name: Self.codexProjectName(path: resolvedPath), @@ -194,6 +194,7 @@ extension CostUsageScanner { var sawTotalTokens = false var costUSD: Double = 0 var sawCost = false + var hasUnpricedUsage = false var standardCostUSD: Double = 0 var sawStandardCost = false var priorityCostUSD: Double = 0 @@ -211,6 +212,8 @@ extension CostUsageScanner { if let costUSD = breakdown.costUSD { self.costUSD += costUSD self.sawCost = true + } else if (breakdown.totalTokens ?? 0) > 0 { + self.hasUnpricedUsage = true } if let standardCostUSD = breakdown.standardCostUSD { self.standardCostUSD += standardCostUSD @@ -233,10 +236,10 @@ extension CostUsageScanner { func build(modelName: String) -> CostUsageDailyReport.ModelBreakdown { CostUsageDailyReport.ModelBreakdown( modelName: modelName, - costUSD: self.sawCost ? self.costUSD : nil, + costUSD: self.sawCost && !self.hasUnpricedUsage ? self.costUSD : nil, totalTokens: self.sawTotalTokens ? self.totalTokens : nil, - standardCostUSD: self.sawStandardCost ? self.standardCostUSD : nil, - priorityCostUSD: self.sawPriorityCost ? self.priorityCostUSD : nil, + standardCostUSD: self.sawStandardCost && !self.hasUnpricedUsage ? self.standardCostUSD : nil, + priorityCostUSD: self.sawPriorityCost && !self.hasUnpricedUsage ? self.priorityCostUSD : nil, standardTokens: self.sawStandardTokens ? self.standardTokens : nil, priorityTokens: self.sawPriorityTokens ? self.priorityTokens : nil) } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 3109a3f8c6..87019c0423 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -1939,7 +1939,7 @@ enum CostUsageScanner { /// Bump when the report pricing formula changes. Rates are resolved when reports are read; /// this fingerprint only invalidates downstream presentation caches such as Workspaces snapshots. - private static let codexCostFormulaVersion = 2 + private static let codexCostFormulaVersion = 3 static func codexPricingKey(modelsDevArtifact: ModelsDevCacheArtifact?) -> String { CostUsagePricingKey.codex( diff --git a/Tests/CodexBarTests/CLICostTests.swift b/Tests/CodexBarTests/CLICostTests.swift index f2c2c0370e..f659343abe 100644 --- a/Tests/CodexBarTests/CLICostTests.swift +++ b/Tests/CodexBarTests/CLICostTests.swift @@ -111,6 +111,85 @@ struct CLICostTests { #expect(output.contains("Not a subscription bill or plan value · local usage × public API prices")) } + @Test + func `codex output marks incomplete aggregates without hiding tokens or priced rows`() throws { + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 402_000, + sessionCostUSD: nil, + last30DaysTokens: 402_110, + last30DaysCostUSD: nil, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-04-07", + inputTokens: 400_000, + outputTokens: 2000, + totalTokens: 402_000, + costUSD: nil, + modelsUsed: ["gpt-5.5"], + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.5", + costUSD: nil, + totalTokens: 402_000), + ]), + CostUsageDailyReport.Entry( + date: "2026-04-08", + inputTokens: 100, + outputTokens: 10, + totalTokens: 110, + costUSD: 1.25, + modelsUsed: ["gpt-5.4"], + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.4", + costUSD: 1.25, + totalTokens: 110), + ]), + ], + projects: [ + CostUsageProjectBreakdown( + name: "synthetic-project", + path: nil, + totalTokens: 402_110, + totalCostUSD: nil, + daily: [], + modelBreakdowns: nil, + sources: [ + CostUsageProjectSourceBreakdown( + name: "synthetic-source", + path: nil, + totalTokens: 402_110, + totalCostUSD: nil, + daily: [], + modelBreakdowns: nil), + ]), + ], + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + + let text = CodexBarCLI.renderCostText(provider: .codex, snapshot: snapshot, useColor: false) + #expect(text.contains("Today: Unavailable (incomplete pricing data) · 402K tokens")) + #expect(text.contains("Last 30 days: Unavailable (incomplete pricing data) · 402K tokens")) + + let grouped = CodexBarCLI.renderCostText( + provider: .codex, + snapshot: snapshot, + groupBy: .project, + useColor: false) + #expect(grouped.contains("synthetic-project: Unavailable (incomplete pricing data) · 402K tokens")) + #expect(grouped.contains("synthetic-source: Unavailable (incomplete pricing data) · 402K tokens")) + + let payload = CodexBarCLI.makeCostPayload(provider: .codex, snapshot: snapshot, error: nil) + let data = try JSONEncoder().encode(payload) + let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let totals = try #require(object["totals"] as? [String: Any]) + let daily = try #require(object["daily"] as? [[String: Any]]) + #expect(object["last30DaysCostUSD"] == nil) + #expect(totals["totalCost"] == nil) + #expect(totals["totalTokens"] as? Int == 402_110) + #expect(daily.first?["totalCost"] == nil) + #expect(daily.last?["totalCost"] as? Double == 1.25) + } + @Test func `encodes cost payload JSON`() throws { let payload = CostPayload( diff --git a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift index e7a42293c9..d21a113319 100644 --- a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift +++ b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift @@ -163,4 +163,69 @@ struct CostUsageDailyReportMergeTests { #expect(merged.summary?.totalTokens == 120) #expect(abs((merged.data.first?.costUSD ?? 0) - 1.25) < 0.000001) } + + @Test + func `strict merge propagates an unpriced model through day and history totals`() throws { + let priced = CostUsageDailyReport( + data: [ + CostUsageDailyReport.Entry( + date: "2026-04-04", + inputTokens: 100, + outputTokens: 20, + totalTokens: 120, + costUSD: 1.25, + modelsUsed: ["gpt-5.4"], + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.4", + costUSD: 1.25, + totalTokens: 120), + ]), + CostUsageDailyReport.Entry( + date: "2026-04-05", + inputTokens: 50, + outputTokens: 10, + totalTokens: 60, + costUSD: 0.75, + modelsUsed: ["gpt-5.4"], + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.4", + costUSD: 0.75, + totalTokens: 60), + ]), + ], + summary: nil) + let ambiguous = CostUsageDailyReport( + data: [ + CostUsageDailyReport.Entry( + date: "2026-04-04", + inputTokens: 400_000, + outputTokens: 2000, + totalTokens: 402_000, + costUSD: nil, + modelsUsed: ["gpt-5.5"], + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.5", + costUSD: nil, + totalTokens: 402_000), + ]), + ], + summary: nil) + + let legacy = CostUsageDailyReport.merged([priced, ambiguous]) + #expect(legacy.data.first?.costUSD == 1.25) + #expect(legacy.summary?.totalCostUSD == 2.0) + + let strict = CostUsageDailyReport.mergedRequiringCompleteCosts([priced, ambiguous]) + let affectedDay = try #require(strict.data.first { $0.date == "2026-04-04" }) + let pricedDay = try #require(strict.data.first { $0.date == "2026-04-05" }) + #expect(affectedDay.costUSD == nil) + #expect(affectedDay.modelBreakdowns?.first { $0.modelName == "gpt-5.4" }?.costUSD == 1.25) + #expect(affectedDay.modelBreakdowns?.first { $0.modelName == "gpt-5.5" }?.costUSD == nil) + #expect(pricedDay.costUSD == 0.75) + #expect(strict.summary?.totalCostUSD == nil) + #expect(strict.summary?.totalTokens == 402_180) + } } diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index 014fceaf06..1b6184a138 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -4,6 +4,55 @@ import Testing @Suite(.serialized) struct CostUsageFetcherTests { + @Test + func `strict token snapshot never rebuilds a partial history cost`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let now = try #require(calendar.date(from: DateComponents( + timeZone: calendar.timeZone, + year: 2026, + month: 4, + day: 8, + hour: 12))) + let report = CostUsageDailyReport( + data: [ + CostUsageDailyReport.Entry( + date: "2026-04-07", + inputTokens: 400_000, + outputTokens: 2000, + totalTokens: 402_000, + costUSD: nil, + modelsUsed: ["gpt-5.5"], + modelBreakdowns: nil), + CostUsageDailyReport.Entry( + date: "2026-04-08", + inputTokens: 100, + outputTokens: 10, + totalTokens: 110, + costUSD: 1.25, + modelsUsed: ["gpt-5.4"], + modelBreakdowns: nil), + ], + summary: CostUsageDailyReport.Summary( + totalInputTokens: 400_100, + totalOutputTokens: 2010, + totalTokens: 402_110, + totalCostUSD: 1.25)) + + let permissive = CostUsageFetcher.tokenSnapshot(from: report, now: now, calendar: calendar) + let strict = CostUsageFetcher.tokenSnapshot( + from: report, + now: now, + calendar: calendar, + requireCompleteCosts: true) + + #expect(permissive.last30DaysCostUSD == 1.25) + #expect(strict.sessionCostUSD == 1.25) + #expect(strict.last30DaysCostUSD == nil) + #expect(strict.last30DaysTokens == 402_110) + #expect(strict.daily.count == 2) + } + @Test func `fetcher scopes codex history to selected codex home`() async throws { let env = try CostUsageTestEnvironment() diff --git a/Tests/CodexBarTests/CostUsageScannerForkSplitTests.swift b/Tests/CodexBarTests/CostUsageScannerForkSplitTests.swift index b6076bddff..6eef95a076 100644 --- a/Tests/CodexBarTests/CostUsageScannerForkSplitTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerForkSplitTests.swift @@ -211,6 +211,90 @@ struct CostUsageScannerForkSplitTests { #expect(report.summary?.totalCostUSD == nil) } + @Test + func `ambiguous model invalidates enclosing totals without hiding priced rows`() throws { + let fixture = try self.makePricingFixture( + parent: (input: 200_000, cached: 20000, output: 1000), + child: (input: 200_000, cached: 20000, output: 1000)) + defer { fixture.environment.cleanup() } + + var cache = try self.cacheByCopyingParentRowsIntoChild(fixture.cache) + let nextDay = try #require(fixture.range.calendar.date(byAdding: .day, value: 1, to: fixture.day)) + let range = CostUsageScanner.CostUsageDayRange( + since: fixture.day, + until: nextDay, + calendar: fixture.range.calendar) + let pricedModel = "gpt-5.4" + let sameDayTokens = (input: 100, cached: 20, output: 10) + let nextDayTokens = (input: 200, cached: 40, output: 20) + + let parent = try #require(cache.files.first { $0.value.sessionId == "parent-session" }) + var parentUsage = parent.value + var sameDayModels = parentUsage.days[fixture.dayKey] ?? [:] + sameDayModels[pricedModel] = [sameDayTokens.input, sameDayTokens.cached, sameDayTokens.output] + parentUsage.days[fixture.dayKey] = sameDayModels + parentUsage.days[range.untilKey] = [ + pricedModel: [nextDayTokens.input, nextDayTokens.cached, nextDayTokens.output], + ] + parentUsage.codexRows = (parentUsage.codexRows ?? []) + [ + CostUsageScanner.CodexUsageRow( + day: fixture.dayKey, + model: pricedModel, + turnID: "same-day-priced-turn", + eventIndex: 1001, + input: sameDayTokens.input, + cached: sameDayTokens.cached, + output: sameDayTokens.output, + pricingModel: pricedModel, + pricingMode: "standard"), + CostUsageScanner.CodexUsageRow( + day: range.untilKey, + model: pricedModel, + turnID: "next-day-priced-turn", + eventIndex: 1002, + input: nextDayTokens.input, + cached: nextDayTokens.cached, + output: nextDayTokens.output, + pricingModel: pricedModel, + pricingMode: "standard"), + ] + cache.files[parent.key] = parentUsage + + var canonicalSameDay = cache.days[fixture.dayKey] ?? [:] + canonicalSameDay[pricedModel] = [sameDayTokens.input, sameDayTokens.cached, sameDayTokens.output] + cache.days[fixture.dayKey] = canonicalSameDay + cache.days[range.untilKey] = [ + pricedModel: [nextDayTokens.input, nextDayTokens.cached, nextDayTokens.output], + ] + + let report = CostUsageScanner.buildCodexReportFromCache(cache: cache, range: range) + let affectedDay = try #require(report.data.first { $0.date == fixture.dayKey }) + let pricedDay = try #require(report.data.first { $0.date == range.untilKey }) + let ambiguousBreakdown = try #require(affectedDay.modelBreakdowns?.first { + $0.modelName == fixture.model + }) + let sameDayBreakdown = try #require(affectedDay.modelBreakdowns?.first { + $0.modelName == pricedModel + }) + let sameDayCost = try #require(CostUsagePricing.codexCostUSD( + model: pricedModel, + inputTokens: sameDayTokens.input, + cachedInputTokens: sameDayTokens.cached, + outputTokens: sameDayTokens.output)) + let nextDayCost = try #require(CostUsagePricing.codexCostUSD( + model: pricedModel, + inputTokens: nextDayTokens.input, + cachedInputTokens: nextDayTokens.cached, + outputTokens: nextDayTokens.output)) + + #expect(ambiguousBreakdown.costUSD == nil) + #expect(abs((sameDayBreakdown.costUSD ?? 0) - sameDayCost) < 1e-12) + #expect(affectedDay.costUSD == nil) + #expect(abs((pricedDay.costUSD ?? 0) - nextDayCost) < 1e-12) + #expect(report.summary?.totalCostUSD == nil) + #expect(report.summary?.totalTokens == 402_330) + } + private struct Fixture { let environment: CostUsageTestEnvironment let range: CostUsageScanner.CostUsageDayRange diff --git a/Tests/CodexBarTests/CostUsageWindowSummaryTests.swift b/Tests/CodexBarTests/CostUsageWindowSummaryTests.swift index e7b150ec9b..709169e9ce 100644 --- a/Tests/CodexBarTests/CostUsageWindowSummaryTests.swift +++ b/Tests/CodexBarTests/CostUsageWindowSummaryTests.swift @@ -42,6 +42,27 @@ struct CostUsageWindowSummaryTests { #expect(summary.totalRequests == nil) } + @Test + func `summary does not expose a partial cost when one populated day is unpriced`() { + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 500, + sessionCostUSD: nil, + last30DaysTokens: 900, + last30DaysCostUSD: nil, + historyDays: 30, + daily: [ + Self.entry(day: "2026-06-30", cost: 4, tokens: 400, requests: 4), + Self.entry(day: "2026-07-01", cost: nil, tokens: 500, requests: 5), + ], + updatedAt: Self.now) + + let summary = snapshot.summary(forLastDays: 7, calendar: Self.utcCalendar) + #expect(summary.entryCount == 2) + #expect(summary.totalCostUSD == nil) + #expect(summary.totalTokens == 900) + #expect(summary.totalRequests == 9) + } + @Test func `comparison summaries keep Gregorian entries under a Buddhist calendar`() throws { let bangkok = try #require(TimeZone(identifier: "Asia/Bangkok")) diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index c9ca73a5ce..a42dfb5f7d 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1311,25 +1311,25 @@ struct ProviderArchitectureGatekeeperTests { reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 180, + line: 186, anchor: "lines.append(Self.costEstimateHint(provider: .codex))", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 203, + line: 215, anchor: "lines.append(Self.costEstimateHint(provider: .codex))", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 390, + line: 443, anchor: "let account = try context.resolvedAccounts(for: .cursor).first", expectedProviderIDs: ["cursor"], reason: "The Cursor-only cookie-settings resolver passes its fixed identity to token-account helpers."), SuppressedProviderReference( path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 391, + line: 444, anchor: "return context.settingsSnapshot(for: .cursor, account: account)?.cursor", expectedProviderIDs: ["cursor"], reason: "The Cursor-only cookie-settings resolver passes its fixed identity to token-account helpers."), @@ -1353,19 +1353,19 @@ struct ProviderArchitectureGatekeeperTests { reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 715, + line: 719, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 790, + line: 794, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 865, + line: 869, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), @@ -3291,7 +3291,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact CLI construct preserves the provider-specific command and output contract."), AllowedProviderConstruct( path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 208, + line: 220, anchor: "provider == .codex", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3299,7 +3299,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact CLI construct preserves the provider-specific command and output contract."), AllowedProviderConstruct( path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 228, + line: 255, anchor: "let projects = provider == .codex", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3307,7 +3307,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact CLI construct preserves the provider-specific command and output contract."), AllowedProviderConstruct( path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 400, + line: 453, anchor: "guard provider == .cursor else { return nil }", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3315,7 +3315,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact CLI construct preserves the provider-specific command and output contract."), AllowedProviderConstruct( path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 420, + line: 473, anchor: "guard provider == .cursor, settings?.cookieSource == .manual else { return nil }", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3379,7 +3379,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 539, + line: 541, anchor: "if provider == .codex {", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3387,15 +3387,15 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 567, + line: 569, anchor: "provider == .claude || (provider == .codex && options.shouldMergePiUsage)", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 5, - expectedReferenceFingerprint: ["claude@0", "codex@0", "codex@10", "codex@15", "codex@27"], + expectedReferenceFingerprint: ["claude@0", "codex@0", "codex@10", "codex@17", "codex@29"], reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 614, + line: 618, anchor: "options.provider == .codex || options.provider == .claude", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 2, @@ -3403,7 +3403,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 641, + line: 645, anchor: "guard provider == .codex || provider == .claude else { return nil }", expectedProviderIDs: ["claude", "codex", "openai"], expectedReferenceCount: 5, @@ -3411,7 +3411,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 1114, + line: 1136, anchor: "if provider == .vertexai {", expectedProviderIDs: ["claude", "vertexai"], expectedReferenceCount: 2, @@ -3419,7 +3419,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 1365, + line: 1390, anchor: "if provider == .cursor {", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1,