From 65ed68e4fcb52c935f348aff09ae7e3bfd49d6aa Mon Sep 17 00:00:00 2001 From: olddonkey Date: Fri, 21 Aug 2026 20:02:37 -0700 Subject: [PATCH 01/34] fix(grok): report real token usage and list-price cost from CLI session logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit two ways and expensive in a third. Wrong tokens: the scanner summed `contextTokensUsed` from `signals.json`, which is the session's ENDING context-window occupancy, not what it consumed. On a real machine that reported 653K where actual consumption was 48.0M. Read the sibling `updates.jsonl` instead, where every `turn_completed` event carries the turn's real usage, and bucket by the per-line timestamp so a session crossing local midnight lands in both days. No cost: `toCostUsageTokenSnapshot` hardcoded nil dollars, and nothing could have priced a Grok model anyway because `codexModelsDevProviderIDs` had no `xai`. Add it, and resolve `grok--build` onto its base catalog model — the `-build` suffix is an artifact of the responses-API surface, not a separate SKU. `grok-build-0.1` is a real model and is never rewritten. Cost is the public xAI card via models.dev, provenance `.listPriceEstimate`, so Grok stays comparable with Claude and Codex. grok's own `costUsdTicks` is deliberately not used for display. A turn's `usage` is the aggregate of `modelCalls` API calls, so tiering on the turn total would push nearly every multi-call turn into the >=200k bracket. Price on the per-call average instead, in closed form over the two synthetic call groups. This under-tiers slightly when context grows within a turn (measured ~4% below the vendor's own accounting on a 27-turn sample, against ~+10% for aggregate tiering); the trade is documented at the call site and pinned by a test. Main-actor cost: the scan ran synchronously inside `@MainActor UsageStore` on every menu-card build, refresh and dashboard load. It now reads the projection the async probe already produced, and the remaining fallback scans on a detached task with one scan in flight at a time. The probe projects the maximum window and consumers narrow it, so `costUsageHistoryDays` and the dashboard's 365-day request are both honoured. Hardening: `modelCalls` comes from a file, so it is validated before it can size any work; parsing is cached per (path, size, mtime) with entries evicted when a file is no longer visited; the cache lock is not held across file reads. Note for upgraders: adding `xai` to `codexModelsDevProviderIDs` changes the Codex pricing-cache key, so the first launch after this re-prices existing Codex history once. Same one-time cost as when kimi and deepseek were added. --- .../CodexBar/SpendDashboardController.swift | 32 +- .../CodexBar/UsageStore+QuotaWarnings.swift | 17 + Sources/CodexBar/UsageStore+TokenCost.swift | 67 +- Sources/CodexBar/UsageStore.swift | 27 +- Sources/CodexBarCore/CostUsageModels.swift | 66 ++ .../Grok/GrokLocalSessionScanner.swift | 799 ++++++++++++++++-- .../Grok/GrokProviderDescriptor.swift | 8 +- .../Providers/Grok/GrokStatusProbe.swift | 6 +- .../Vendored/CostUsage/CostUsagePricing.swift | 11 + .../GrokCostUsagePricingResolutionTests.swift | 59 ++ .../GrokCostUsagePricingTests.swift | 332 ++++++++ .../GrokLocalSessionScannerTestSupport.swift | 203 +++++ .../GrokLocalSessionScannerTests.swift | 464 ++++++++-- .../GrokXAISpendCatalogTests.swift | 2 + .../ProviderArchitectureGatekeeperTests.swift | 32 +- 15 files changed, 1929 insertions(+), 196 deletions(-) create mode 100644 Tests/CodexBarTests/GrokCostUsagePricingResolutionTests.swift create mode 100644 Tests/CodexBarTests/GrokCostUsagePricingTests.swift create mode 100644 Tests/CodexBarTests/GrokLocalSessionScannerTestSupport.swift diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index e007fb6765..9b7ce0a141 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -293,15 +293,21 @@ enum SpendDashboardSource { // Provider-specific by design: Grok local session tokens are independent of the // remote billing snapshot, so a failed probe still publishes readable logs. if provider == .grok { - if let snapshot = store.tokenSnapshot( - fromProviderSnapshot: store.snapshot(for: .grok), - provider: .grok, - historyDays: Self.scanDays) - { + let grokSnapshot = if let usage = store.snapshot(for: .grok) { + store.tokenSnapshot( + fromProviderSnapshot: usage, + provider: .grok, + historyDays: Self.scanDays) + } else if let published = store.tokenSnapshotPublicationForCurrentProviderConfig(for: .grok) { + published.snapshot + } else { + await store.scanAndPublishGrokLocalTokenSnapshot(historyDays: Self.scanDays) + } + if let grokSnapshot { inputs.append(SpendDashboardModel.ProviderInput( provider: .grok, displayName: store.metadata(for: .grok).displayName, - snapshot: snapshot)) + snapshot: grokSnapshot)) } else { confirmedEmptySourceIDs.insert(UsageProvider.grok.rawValue) } @@ -868,13 +874,15 @@ enum SpendDashboardSource { provider: UsageProvider, publication: CurrentProviderConfigTokenPublication) -> CostUsageTokenSnapshot? { - // Provider-specific by design: Grok's catalog input is the local session scan, even when - // the remote billing snapshot is missing. + // Provider-specific by design: a failed Grok probe publishes its detached local scan. if provider == .grok { - return store.tokenSnapshot( - fromProviderSnapshot: store.snapshot(for: .grok), - provider: .grok, - historyDays: self.scanDays) + if let usage = store.snapshot(for: .grok) { + return store.tokenSnapshot( + fromProviderSnapshot: usage, + provider: .grok, + historyDays: self.scanDays) + } + return publication.snapshot } if UsageStore.tokenCostRequiresProviderSnapshot(provider), let usage = store.snapshot(for: provider.instanceID), diff --git a/Sources/CodexBar/UsageStore+QuotaWarnings.swift b/Sources/CodexBar/UsageStore+QuotaWarnings.swift index bd88c71965..d9e5cabe24 100644 --- a/Sources/CodexBar/UsageStore+QuotaWarnings.swift +++ b/Sources/CodexBar/UsageStore+QuotaWarnings.swift @@ -41,6 +41,23 @@ extension UsageStore { let displayName: String? } + func postQuotaWarning(_ event: QuotaWarningEvent, provider: UsageProvider) { + self.sessionQuotaNotifier.postQuotaWarning( + event: event, + provider: provider, + soundEnabled: self.settings.quotaWarningSoundEnabled, + onScreenAlertEnabled: self.settings.quotaWarningOnScreenAlertEnabled) + } + + func postPredictivePaceWarning(_ event: PredictivePaceWarningEvent, provider: UsageProvider, now: Date) { + self.sessionQuotaNotifier.postPredictivePaceWarning( + event: event, + provider: provider, + soundEnabled: self.settings.quotaWarningSoundEnabled, + onScreenAlertEnabled: self.settings.quotaWarningOnScreenAlertEnabled, + now: now) + } + func handleQuotaWarningTransitions( provider: UsageProvider, snapshot: UsageSnapshot, diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 076cec6589..53d88e2b19 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -499,8 +499,8 @@ extension UsageStore { { let windowDays = historyDays ?? self.settings.costUsageHistoryDays // Provider-specific by design: snapshot-backed spend sources own their live billing - // projection. Grok contributes local session tokens only; xAI contributes Management API - // daily spend only. Neither converts a quota or prepaid balance into dollars. + // projection. Grok contributes local session list-price estimates; xAI contributes + // Management API daily spend. Neither converts a quota or prepaid balance into dollars. switch provider { case .openai: return snapshot?.openAIAPIUsage?.toCostUsageTokenSnapshot() @@ -525,6 +525,69 @@ extension UsageStore { } } + @discardableResult + func scanAndPublishGrokLocalTokenSnapshot(historyDays: Int) async -> CostUsageTokenSnapshot? { + // Provider-specific by design: this fallback owns Grok's local session scan and publication. + let provider = UsageProvider.grok + let requestedHistoryDays = min(max(1, historyDays), GrokLocalSessionScanner.maximumLookbackDays) + if let publication = self.tokenSnapshotPublicationForCurrentProviderConfig(for: provider) { + return publication.snapshot?.narrowed( + toHistoryDays: requestedHistoryDays, + calendar: self.settings.costUsageBucketCalendar) + } + if let task = self.grokLocalTokenScanTask { + return await task.value?.narrowed( + toHistoryDays: requestedHistoryDays, + calendar: self.settings.costUsageBucketCalendar) + } + + let environment = self.environmentBase + let publicationRevision = self.providerPublicationRevision(for: provider) + let providerConfigRevision = self.settings.providerConfigRevision(for: provider) + let scannerOverride = self._test_grokLocalTokenScannerOverride + let token = UUID() + let task = Task { @MainActor [weak self] () -> CostUsageTokenSnapshot? in + let snapshot: CostUsageTokenSnapshot? + if let scannerOverride { + snapshot = await scannerOverride(GrokLocalSessionScanner.maximumLookbackDays) + } else { + let scanTask = Task.detached(priority: .utility) { + GrokLocalSessionScanner.summarize( + env: environment, + lookbackDays: GrokLocalSessionScanner.maximumLookbackDays) + .toCostUsageTokenSnapshot(historyDays: GrokLocalSessionScanner.maximumLookbackDays) + } + snapshot = await withTaskCancellationHandler { + await scanTask.value + } onCancel: { + scanTask.cancel() + } + } + guard let self, + !Task.isCancelled, + self.providerPublicationRevisionIsCurrent(publicationRevision, for: provider), + self.settings.providerConfigRevision(for: provider) == providerConfigRevision, + self.isEnabled(provider) + else { return nil } + if let snapshot { + self.publishTokenSnapshot(snapshot, for: provider) + } else { + self.publishConfirmedEmptyTokenSnapshot(for: provider) + } + return snapshot + } + self.grokLocalTokenScanToken = token + self.grokLocalTokenScanTask = task + let snapshot = await task.value + if self.grokLocalTokenScanToken == token { + self.grokLocalTokenScanTask = nil + self.grokLocalTokenScanToken = nil + } + return snapshot?.narrowed( + toHistoryDays: requestedHistoryDays, + calendar: self.settings.costUsageBucketCalendar) + } + nonisolated static func tokenCostRequiresProviderSnapshot(_ provider: UsageProvider) -> Bool { // Provider-specific by design: these providers project live usage snapshots into the // shared spend catalog instead of running the local CostUsageFetcher JSONL pipeline. diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 5dec4ba76f..7ebc90a873 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -186,6 +186,8 @@ final class UsageStore { var tokenSnapshots: [ProviderInstanceID: CostUsageTokenSnapshot] = [:] var tokenSnapshotPublications: [ProviderInstanceID: TokenSnapshotPublication] = [:] var tokenSnapshotPublicationRevisions: [ProviderInstanceID: UInt64] = [:] + @ObservationIgnored var grokLocalTokenScanTask: Task? + @ObservationIgnored var grokLocalTokenScanToken: UUID? var spendDashboardTokenPublications: [ProviderInstanceID: TokenSnapshotPublication] = [:] var spendDashboardTokenPublicationRevisions: [ProviderInstanceID: UInt64] = [:] @ObservationIgnored var spendDashboardTokenIncorporatedTriggers: @@ -284,6 +286,8 @@ final class UsageStore { Date, String?, Int) async throws -> CostUsageTokenSnapshot)? + @ObservationIgnored var _test_grokLocalTokenScannerOverride: (@MainActor ( + Int) async -> CostUsageTokenSnapshot?)? @ObservationIgnored var _test_cachedCodexTokenSnapshotLoaderOverride: (@MainActor ( Date, String?, @@ -976,6 +980,7 @@ final class UsageStore { self.codexPlanHistoryBackfillTask?.cancel() self.resetBoundaryRefreshTask?.cancel() self.planUtilizationHistoryLoadTask?.cancel() + self.grokLocalTokenScanTask?.cancel() } enum SessionQuotaWindowSource: String { @@ -984,23 +989,6 @@ final class UsageStore { case antigravityQuotaSummary case antigravityLegacy } - - func postQuotaWarning(_ event: QuotaWarningEvent, provider: UsageProvider) { - self.sessionQuotaNotifier.postQuotaWarning( - event: event, - provider: provider, - soundEnabled: self.settings.quotaWarningSoundEnabled, - onScreenAlertEnabled: self.settings.quotaWarningOnScreenAlertEnabled) - } - - func postPredictivePaceWarning(_ event: PredictivePaceWarningEvent, provider: UsageProvider, now: Date) { - self.sessionQuotaNotifier.postPredictivePaceWarning( - event: event, - provider: provider, - soundEnabled: self.settings.quotaWarningSoundEnabled, - onScreenAlertEnabled: self.settings.quotaWarningOnScreenAlertEnabled, - now: now) - } } extension UsageStore { @@ -1631,6 +1619,11 @@ extension UsageStore { self.cancelCodexCostCatchUp() self.cancelSpendDashboardCodexCostCatchUp() } + if provider == .grok { + self.grokLocalTokenScanTask?.cancel() + self.grokLocalTokenScanTask = nil + self.grokLocalTokenScanToken = nil + } self.clearTokenSnapshot(for: provider) self.clearSpendDashboardTokenSnapshot(for: provider) self.tokenErrors[provider.instanceID] = nil diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index 5447fe38e7..cd6ef816db 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -178,6 +178,72 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { Self.entry(in: self.daily, forLocalDayContaining: self.updatedAt, calendar: calendar) } + /// Reprojects this snapshot from its retained daily rows into a smaller rolling window. + public func narrowed(toHistoryDays requestedDays: Int, calendar: Calendar = .current) -> Self { + let days = min(max(1, requestedDays), max(1, self.historyDays)) + let today = calendar.startOfDay(for: self.updatedAt) + let start = calendar.date(byAdding: .day, value: -(days - 1), to: today) ?? today + let startKey = CostUsageLocalDay.key(from: start, calendar: calendar) + let endKey = CostUsageLocalDay.key(from: today, calendar: calendar) + let entries = self.daily.filter { entry in + guard let dayKey = Self.localDayKey(for: entry.date, calendar: calendar) else { return false } + return dayKey >= startKey && dayKey <= endKey + } + let derived = CostUsageFetcher.tokenSnapshot( + from: CostUsageDailyReport(data: entries, summary: nil), + now: self.updatedAt, + historyDays: days, + useCurrentLocalDayForSession: true, + calendar: calendar, + historyCoverageIsEstablished: self.historyCoverageIsEstablished, + meteredCostUSD: days == self.historyDays ? self.meteredCostUSD : nil, + costProvenance: self.costProvenance, + credentialScopeFingerprint: self.credentialScopeFingerprint, + historyLabel: self.historyLabel, + projects: self.projects, + sessions: self.sessions, + updatedAt: self.updatedAt) + let sessionRequests: Int? = if let current = Self.entry( + in: entries, + forLocalDayContaining: self.updatedAt, + calendar: calendar) + { + current.requestCount + } else if !entries.isEmpty || self.historyCoverageIsEstablished { + 0 + } else { + nil + } + let requests = entries.compactMap(\.requestCount) + let allEntriesCarryRequests = !entries.isEmpty && entries.allSatisfy { $0.requestCount != nil } + let totalRequests: Int? = if allEntriesCarryRequests { + requests.reduce(0, +) + } else if self.historyCoverageIsEstablished, entries.isEmpty { + 0 + } else { + nil + } + return Self( + sessionTokens: derived.sessionTokens, + sessionCostUSD: derived.sessionCostUSD, + sessionRequests: sessionRequests, + last30DaysTokens: derived.last30DaysTokens, + last30DaysCostUSD: derived.last30DaysCostUSD, + last30DaysRequests: totalRequests, + currencyCode: self.currencyCode, + historyDays: days, + historyCoverageIsEstablished: self.historyCoverageIsEstablished, + historyLabel: self.historyLabel, + meteredCostUSD: derived.meteredCostUSD, + costProvenance: self.costProvenance, + credentialScopeFingerprint: self.credentialScopeFingerprint, + daily: entries, + projects: self.projects, + sessions: self.sessions, + hourly: self.hourly, + updatedAt: self.updatedAt) + } + public func summary(forLastDays requestedDays: Int, calendar: Calendar = .current) -> CostUsageWindowSummary { let days = max(1, requestedDays) let today = calendar.startOfDay(for: self.updatedAt) diff --git a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift index c316a43d4c..4e63efdec6 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift @@ -3,20 +3,55 @@ import Foundation /// One local-calendar day of Grok session-token activity. public struct GrokLocalDailyBucket: Sendable, Equatable { public let date: String + public let inputTokens: Int + public let cacheReadTokens: Int + public let cacheCreationTokens: Int + public let outputTokens: Int + public let reasoningTokens: Int public let totalTokens: Int public let sessionCount: Int + public let requestCount: Int + public let costUSD: Double? public let models: [String] + public let modelBreakdowns: [CostUsageDailyReport.ModelBreakdown] + public let unpricedRequestCount: Int + public let estimatedRequestCount: Int - public init(date: String, totalTokens: Int, sessionCount: Int, models: [String]) { + public init( + date: String, + inputTokens: Int = 0, + cacheReadTokens: Int = 0, + cacheCreationTokens: Int = 0, + outputTokens: Int = 0, + reasoningTokens: Int = 0, + totalTokens: Int, + sessionCount: Int, + requestCount: Int? = nil, + costUSD: Double? = nil, + models: [String], + modelBreakdowns: [CostUsageDailyReport.ModelBreakdown] = [], + unpricedRequestCount: Int = 0, + estimatedRequestCount: Int = 0) + { self.date = date + self.inputTokens = inputTokens + self.cacheReadTokens = cacheReadTokens + self.cacheCreationTokens = cacheCreationTokens + self.outputTokens = outputTokens + self.reasoningTokens = reasoningTokens self.totalTokens = totalTokens self.sessionCount = sessionCount + self.requestCount = requestCount ?? sessionCount + self.costUSD = costUSD self.models = models + self.modelBreakdowns = modelBreakdowns + self.unpricedRequestCount = unpricedRequestCount + self.estimatedRequestCount = estimatedRequestCount } } -/// Aggregated stats from local `~/.grok/sessions/**/signals.json` files. -/// Used as a local fallback view when the JSON-RPC billing call is unavailable. +/// Aggregated stats from local `~/.grok/sessions/**/updates.jsonl` files. +/// `signals.json` is metadata-only fallback when a session has no completed turns. public struct GrokLocalSessionSummary: Sendable { public let sessionCount: Int public let totalTokens: Int @@ -44,133 +79,367 @@ public struct GrokLocalSessionSummary: Sendable { self.scannedAt = scannedAt } - /// Local session tokens only. SuperGrok credits are a quota, not dollars, so this never invents spend. + /// Local tokens priced at public API list rates; this is an estimate, not a Grok bill. public func toCostUsageTokenSnapshot(historyDays: Int) -> CostUsageTokenSnapshot? { let entries = self.daily.map { bucket in CostUsageDailyReport.Entry( date: bucket.date, - inputTokens: nil, - outputTokens: nil, + inputTokens: bucket.inputTokens, + outputTokens: bucket.outputTokens, + cacheReadTokens: bucket.cacheReadTokens, + cacheCreationTokens: bucket.cacheCreationTokens, + reasoningTokens: bucket.reasoningTokens, totalTokens: bucket.totalTokens, - requestCount: bucket.sessionCount, - costUSD: nil, + requestCount: bucket.requestCount, + costUSD: bucket.costUSD, modelsUsed: bucket.models.isEmpty ? nil : bucket.models, - modelBreakdowns: nil) + modelBreakdowns: bucket.modelBreakdowns.isEmpty ? nil : bucket.modelBreakdowns, + unpricedRequestCount: bucket.unpricedRequestCount > 0 ? bucket.unpricedRequestCount : nil, + unmeteredRequestCount: nil, + estimatedRequestCount: bucket.estimatedRequestCount > 0 ? bucket.estimatedRequestCount : nil) } guard !entries.isEmpty else { return nil } let todayKey = GrokLocalSessionScanner.dayKey(for: self.scannedAt, calendar: .current) - let todayTokens = todayKey.flatMap { key in self.daily.first { $0.date == key }?.totalTokens } + let today = todayKey.flatMap { key in self.daily.first { $0.date == key } } + let pricedDays = self.daily.compactMap(\.costUSD) return CostUsageTokenSnapshot( - sessionTokens: todayTokens, - sessionCostUSD: nil, + sessionTokens: today?.totalTokens, + sessionCostUSD: today?.costUSD, + sessionRequests: today?.requestCount, last30DaysTokens: self.totalTokens, - last30DaysCostUSD: nil, + last30DaysCostUSD: pricedDays.isEmpty ? nil : pricedDays.reduce(0, +), + last30DaysRequests: self.daily.reduce(0) { $0 + $1.requestCount }, historyDays: historyDays, historyCoverageIsEstablished: true, - costProvenance: .unknown, + costProvenance: .listPriceEstimate, daily: entries, updatedAt: self.scannedAt) } } +struct GrokLocalSessionParseCacheMetrics: Sendable, Equatable { + let fileDecodeCount: Int + let jsonDecodeCount: Int +} + +private struct GrokParsedTokenUsage: Sendable { + let inputTokens: Int + let outputTokens: Int + let totalTokens: Int + let cachedReadTokens: Int + let cacheCreationTokens: Int + let reasoningTokens: Int + let modelCalls: Int? +} + +private struct GrokParsedTurn: Sendable { + let timestamp: Date + let usage: GrokParsedTokenUsage + let modelUsage: [String: GrokParsedTokenUsage] +} + +private final class GrokLocalSessionParseCache: @unchecked Sendable { + private struct Entry { + let size: Int + let mtimeIntervalSince1970: TimeInterval + let turns: [GrokParsedTurn] + } + + private let lock = NSLock() + private var entries: [String: Entry] = [:] + private var fileDecodeCount = 0 + private var jsonDecodeCount = 0 + + func turns( + path: String, + size: Int, + mtimeIntervalSince1970: TimeInterval, + decode: () -> (turns: [GrokParsedTurn], jsonDecodeCount: Int)) -> [GrokParsedTurn] + { + self.lock.lock() + let observedIdentity = self.entries[path].map { ($0.size, $0.mtimeIntervalSince1970) } + if let entry = self.entries[path], + entry.size == size, + entry.mtimeIntervalSince1970 == mtimeIntervalSince1970 + { + self.lock.unlock() + return entry.turns + } + self.lock.unlock() + + let decoded = decode() + self.lock.lock() + defer { self.lock.unlock() } + self.fileDecodeCount += 1 + self.jsonDecodeCount += decoded.jsonDecodeCount + if let entry = self.entries[path] { + if entry.size == size, + entry.mtimeIntervalSince1970 == mtimeIntervalSince1970 + { + return entry.turns + } + if observedIdentity?.0 != entry.size || + observedIdentity?.1 != entry.mtimeIntervalSince1970 + { + // A concurrent scan cached a different file identity while this decode was in flight. + // Return this scan's value without replacing the newer entry. + return decoded.turns + } + } else if observedIdentity != nil { + // A concurrent eviction happened while this decode was in flight. + return decoded.turns + } + self.entries[path] = Entry( + size: size, + mtimeIntervalSince1970: mtimeIntervalSince1970, + turns: decoded.turns) + return decoded.turns + } + + func retainEntries(at visitedPaths: Set) { + self.lock.lock() + defer { self.lock.unlock() } + self.entries = self.entries.filter { visitedPaths.contains($0.key) } + } + + func entryCount() -> Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.entries.count + } + + func metrics() -> GrokLocalSessionParseCacheMetrics { + self.lock.lock() + defer { self.lock.unlock() } + return GrokLocalSessionParseCacheMetrics( + fileDecodeCount: self.fileDecodeCount, + jsonDecodeCount: self.jsonDecodeCount) + } + + func reset() { + self.lock.lock() + defer { self.lock.unlock() } + self.entries.removeAll() + self.fileDecodeCount = 0 + self.jsonDecodeCount = 0 + } +} + public enum GrokLocalSessionScanner { public static let defaultLookbackDays = 30 + public static let maximumLookbackDays = 365 + + private static let maximumValidatedModelCalls = 10000 + + private struct SessionFiles { + var updates: URL? + var signals: URL? + } + + private struct FileIdentity { + let size: Int + let modificationDate: Date + } + + private struct MutableModelBreakdown { + var inputTokens = 0 + var cacheReadTokens = 0 + var cacheCreationTokens = 0 + var outputTokens = 0 + var reasoningTokens = 0 + var totalTokens = 0 + var requestCount = 0 + var costUSD = 0.0 + var hasPricedCost = false + } + + private struct MutableDailyBucket { + var inputTokens = 0 + var cacheReadTokens = 0 + var cacheCreationTokens = 0 + var outputTokens = 0 + var reasoningTokens = 0 + var totalTokens = 0 + var requestCount = 0 + var sessionIDs: Set = [] + var modelCounts: [String: Int] = [:] + var modelBreakdowns: [String: MutableModelBreakdown] = [:] + var costUSD = 0.0 + var hasPricedCost = false + var unpricedRequestCount = 0 + var estimatedRequestCount = 0 + } + + private struct PricingContext { + let modelsDevCatalog: ModelsDevCatalog? + let modelsDevCacheRoot: URL? + let customPricing: CostUsageCustomPricing? + } + + private struct ScanAggregation { + var modelCounts: [String: Int] = [:] + var daily: [String: MutableDailyBucket] = [:] + } + + private static let parseCache = GrokLocalSessionParseCache() + private static let turnCompletedNeedle = Data("turn_completed".utf8) - /// Walk `~/.grok/sessions///signals.json` and aggregate stats. + /// Walk `~/.grok/sessions///updates.jsonl` and aggregate completed turns. public static func summarize( env: [String: String] = ProcessInfo.processInfo.environment, fileManager: FileManager = .default, lookbackDays: Int = defaultLookbackDays, now: Date = .init()) -> GrokLocalSessionSummary + { + self.summarize( + env: env, + fileManager: fileManager, + lookbackDays: lookbackDays, + now: now, + pricing: PricingContext( + modelsDevCatalog: nil, + modelsDevCacheRoot: nil, + customPricing: .empty)) + } + + static func summarize( + env: [String: String], + fileManager: FileManager = .default, + lookbackDays: Int = defaultLookbackDays, + now: Date = .init(), + modelsDevCatalog: ModelsDevCatalog, + modelsDevCacheRoot: URL? = nil, + customPricing: CostUsageCustomPricing? = .empty) -> GrokLocalSessionSummary + { + self.summarize( + env: env, + fileManager: fileManager, + lookbackDays: lookbackDays, + now: now, + pricing: PricingContext( + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot, + customPricing: customPricing)) + } + + static func summarize( + env: [String: String], + fileManager: FileManager = .default, + lookbackDays: Int = defaultLookbackDays, + now: Date = .init(), + modelsDevCacheRoot: URL, + customPricing: CostUsageCustomPricing? = .empty) -> GrokLocalSessionSummary + { + self.summarize( + env: env, + fileManager: fileManager, + lookbackDays: lookbackDays, + now: now, + pricing: PricingContext( + modelsDevCatalog: nil, + modelsDevCacheRoot: modelsDevCacheRoot, + customPricing: customPricing)) + } + + private static func summarize( + env: [String: String], + fileManager: FileManager, + lookbackDays: Int, + now: Date, + pricing: PricingContext) -> GrokLocalSessionSummary { let root = GrokCredentialsStore.grokHomeURL(env: env, fileManager: fileManager) .appendingPathComponent("sessions", isDirectory: true) + var visitedCachePaths: Set = [] + defer { self.parseCache.retainEntries(at: visitedCachePaths) } guard let rootEnum = fileManager.enumerator( at: root, - includingPropertiesForKeys: [.contentModificationDateKey, .isDirectoryKey], + includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey, .isDirectoryKey], options: [.skipsHiddenFiles]) else { - return GrokLocalSessionSummary( - sessionCount: 0, - totalTokens: 0, - lastSessionAt: nil, - primaryModel: nil, - models: [], - scannedAt: now) + return self.emptySummary(now: now) + } + + var sessions: [String: SessionFiles] = [:] + while let url = rootEnum.nextObject() as? URL { + guard !Task.isCancelled else { return self.emptySummary(now: now) } + let name = url.lastPathComponent + guard name == "updates.jsonl" || name == "signals.json" else { continue } + let sessionPath = url.deletingLastPathComponent().path + if name == "updates.jsonl" { + sessions[sessionPath, default: SessionFiles()].updates = url + } else { + sessions[sessionPath, default: SessionFiles()].signals = url + } } let calendar = Calendar.current - let lookbackCutoff = calendar.date(byAdding: .day, value: -lookbackDays, to: now) ?? now + let lookbackCutoff = calendar.date(byAdding: .day, value: -max(0, lookbackDays), to: now) ?? now var sessionCount = 0 - var totalTokens = 0 var lastSessionAt: Date? - var modelCounts: [String: Int] = [:] - var dailyTokens: [String: Int] = [:] - var dailySessions: [String: Int] = [:] - var dailyModels: [String: [String: Int]] = [:] + var aggregation = ScanAggregation() - while let url = rootEnum.nextObject() as? URL { - guard url.lastPathComponent == "signals.json" else { continue } - let attrs = try? url.resourceValues(forKeys: [.contentModificationDateKey]) - let mtime = attrs?.contentModificationDate ?? Date.distantPast - guard mtime >= lookbackCutoff else { continue } - - guard let data = try? Data(contentsOf: url), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] - else { continue } - - sessionCount += 1 - let beforeCompaction = (json["totalTokensBeforeCompaction"] as? Int) ?? 0 - let contextUsed = (json["contextTokensUsed"] as? Int) ?? 0 - let sessionTokens = beforeCompaction + contextUsed - totalTokens += sessionTokens - - if mtime > (lastSessionAt ?? Date.distantPast) { - lastSessionAt = mtime - } - - var sessionModels: [String] = [] - if let primary = (json["primaryModelId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), - !primary.isEmpty + for (sessionPath, files) in sessions { + guard !Task.isCancelled else { return self.emptySummary(now: now) } + var updatesYieldedCompletedTurns = false + if let updates = files.updates, + let identity = self.fileIdentity(for: updates), + identity.modificationDate >= lookbackCutoff { - modelCounts[primary, default: 0] += 1 - sessionModels.append(primary) - } - if let models = json["modelsUsed"] as? [String] { - for model in models { - let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { - modelCounts[trimmed, default: 0] += 1 - sessionModels.append(trimmed) + visitedCachePaths.insert(updates.path) + let turns = self.parseCache.turns( + path: updates.path, + size: identity.size, + mtimeIntervalSince1970: identity.modificationDate.timeIntervalSince1970) + { + self.decodeTurns(at: updates) + } + updatesYieldedCompletedTurns = !turns.isEmpty + let currentTurns = turns.filter { $0.timestamp >= lookbackCutoff } + if !currentTurns.isEmpty { + sessionCount += 1 + for turn in currentTurns { + guard !Task.isCancelled else { return self.emptySummary(now: now) } + if turn.timestamp > (lastSessionAt ?? Date.distantPast) { + lastSessionAt = turn.timestamp + } + self.aggregate( + turn: turn, + sessionPath: sessionPath, + calendar: calendar, + aggregation: &aggregation, + pricing: pricing) } } } - if let day = Self.dayKey(for: mtime, calendar: calendar) { - dailyTokens[day, default: 0] += sessionTokens - dailySessions[day, default: 0] += 1 - for model in sessionModels { - dailyModels[day, default: [:]][model, default: 0] += 1 + if !updatesYieldedCompletedTurns, + let fallback = files.signals, + let identity = self.fileIdentity(for: fallback), + identity.modificationDate >= lookbackCutoff, + let metadataModels = self.readSignalsMetadata(at: fallback) + { + sessionCount += 1 + if identity.modificationDate > (lastSessionAt ?? Date.distantPast) { + lastSessionAt = identity.modificationDate + } + for model in metadataModels { + aggregation.modelCounts[model, default: 0] += 1 } } } - let sortedModels = modelCounts.sorted { $0.value > $1.value }.map(\.key) - let daily = dailyTokens.keys.sorted().map { day in - let models = (dailyModels[day] ?? [:]).sorted { $0.value > $1.value }.map(\.key) - return GrokLocalDailyBucket( - date: day, - totalTokens: dailyTokens[day] ?? 0, - sessionCount: dailySessions[day] ?? 0, - models: models) + let sortedModels = self.sortedModels(aggregation.modelCounts) + let buckets = aggregation.daily.keys.sorted().map { day in + self.finalize(day: day, bucket: aggregation.daily[day] ?? MutableDailyBucket()) } return GrokLocalSessionSummary( sessionCount: sessionCount, - totalTokens: totalTokens, + totalTokens: buckets.reduce(0) { $0 + $1.totalTokens }, lastSessionAt: lastSessionAt, primaryModel: sortedModels.first, models: sortedModels, - daily: daily, + daily: buckets, scannedAt: now) } @@ -187,6 +456,384 @@ public enum GrokLocalSessionScanner { } } + static func parseCacheMetricsForTesting() -> GrokLocalSessionParseCacheMetrics { + self.parseCache.metrics() + } + + static func resetParseCacheForTesting() { + self.parseCache.reset() + } + + static func parseCacheEntryCountForTesting() -> Int { + self.parseCache.entryCount() + } + + private static func emptySummary(now: Date) -> GrokLocalSessionSummary { + GrokLocalSessionSummary( + sessionCount: 0, + totalTokens: 0, + lastSessionAt: nil, + primaryModel: nil, + models: [], + scannedAt: now) + } + + private static func fileIdentity(for url: URL) -> FileIdentity? { + guard let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey]), + let size = values.fileSize, + let modificationDate = values.contentModificationDate + else { return nil } + return FileIdentity(size: size, modificationDate: modificationDate) + } + + private static func decodeTurns(at url: URL) -> (turns: [GrokParsedTurn], jsonDecodeCount: Int) { + guard let data = try? Data(contentsOf: url) else { return ([], 0) } + var turns: [GrokParsedTurn] = [] + var jsonDecodeCount = 0 + for line in data.split(separator: 0x0A, omittingEmptySubsequences: true) { + guard line.range(of: self.turnCompletedNeedle) != nil else { continue } + jsonDecodeCount += 1 + guard let turn = self.decodeTurn(Data(line)) else { continue } + turns.append(turn) + } + return (turns, jsonDecodeCount) + } + + private static func decodeTurn(_ data: Data) -> GrokParsedTurn? { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let timestamp = self.integer(json["timestamp"]), + let params = json["params"] as? [String: Any], + let update = params["update"] as? [String: Any], + update["sessionUpdate"] as? String == "turn_completed", + let usageObject = update["usage"] as? [String: Any], + !usageObject.isEmpty + else { return nil } + + let usage = self.tokenUsage(from: usageObject) + var modelUsage: [String: GrokParsedTokenUsage] = [:] + if let models = usageObject["modelUsage"] as? [String: Any] { + for (rawSKU, value) in models { + let sku = rawSKU.trimmingCharacters(in: .whitespacesAndNewlines) + guard !sku.isEmpty, let object = value as? [String: Any], !object.isEmpty else { continue } + modelUsage[sku] = self.tokenUsage(from: object) + } + } + return GrokParsedTurn( + timestamp: Date(timeIntervalSince1970: TimeInterval(timestamp)), + usage: usage, + modelUsage: modelUsage) + } + + private static func tokenUsage(from object: [String: Any]) -> GrokParsedTokenUsage { + let inputTokens = max(0, self.integer(object["inputTokens"]) ?? 0) + let outputTokens = max(0, self.integer(object["outputTokens"]) ?? 0) + let computedTotalTokens = inputTokens + outputTokens + let reportedTotalTokens = max(0, self.integer(object["totalTokens"]) ?? 0) + return GrokParsedTokenUsage( + inputTokens: inputTokens, + outputTokens: outputTokens, + totalTokens: reportedTotalTokens > 0 || computedTotalTokens == 0 + ? reportedTotalTokens + : computedTotalTokens, + cachedReadTokens: max(0, self.integer(object["cachedReadTokens"]) ?? 0), + cacheCreationTokens: max(0, self.integer(object["cacheCreationTokens"]) ?? 0), + reasoningTokens: max(0, self.integer(object["reasoningTokens"]) ?? 0), + modelCalls: self.integer(object["modelCalls"])) + } + + private static func integer(_ value: Any?) -> Int? { + if let value = value as? Int { return value } + if let value = value as? NSNumber { return value.intValue } + return nil + } + + private static func readSignalsMetadata(at url: URL) -> [String]? { + guard let data = try? Data(contentsOf: url), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return nil } + var models: [String] = [] + if let primary = self.nonEmptyString(json["primaryModelId"] as? String) { + models.append(primary) + } + if let used = json["modelsUsed"] as? [String] { + models.append(contentsOf: used.compactMap(self.nonEmptyString)) + } + return Array(Set(models)).sorted() + } + + private static func nonEmptyString(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed?.isEmpty == false ? trimmed : nil + } + + private static func aggregate( + turn: GrokParsedTurn, + sessionPath: String, + calendar: Calendar, + aggregation: inout ScanAggregation, + pricing: PricingContext) + { + guard let day = self.dayKey(for: turn.timestamp, calendar: calendar) else { return } + var bucket = aggregation.daily[day] ?? MutableDailyBucket() + bucket.inputTokens += turn.usage.inputTokens + bucket.cacheReadTokens += turn.usage.cachedReadTokens + bucket.cacheCreationTokens += turn.usage.cacheCreationTokens + bucket.outputTokens += turn.usage.outputTokens + bucket.reasoningTokens += turn.usage.reasoningTokens + bucket.totalTokens += turn.usage.totalTokens + bucket.sessionIDs.insert(sessionPath) + + if turn.modelUsage.isEmpty { + let requests = self.requestCount(for: turn.usage) + bucket.requestCount += requests + bucket.unpricedRequestCount += requests + } + for (sku, usage) in turn.modelUsage { + let requests = self.requestCount(for: usage) + bucket.requestCount += requests + aggregation.modelCounts[sku, default: 0] += requests + bucket.modelCounts[sku, default: 0] += requests + var breakdown = bucket.modelBreakdowns[sku] ?? MutableModelBreakdown() + breakdown.inputTokens += usage.inputTokens + breakdown.cacheReadTokens += usage.cachedReadTokens + breakdown.cacheCreationTokens += usage.cacheCreationTokens + breakdown.outputTokens += usage.outputTokens + breakdown.reasoningTokens += usage.reasoningTokens + breakdown.totalTokens += usage.totalTokens + breakdown.requestCount += requests + + if let cost = self.costUSD( + sku: sku, + usage: usage, + pricingDate: turn.timestamp, + pricing: pricing) + { + breakdown.costUSD += cost + breakdown.hasPricedCost = true + bucket.costUSD += cost + bucket.hasPricedCost = true + } else { + bucket.unpricedRequestCount += requests + } + bucket.modelBreakdowns[sku] = breakdown + } + aggregation.daily[day] = bucket + } + + private static func costUSD( + sku: String, + usage: GrokParsedTokenUsage, + pricingDate: Date, + pricing: PricingContext) -> Double? + { + let model = "xai/\(sku)" + guard let resolvedPricing = CostUsagePricing.resolvedCodexPricing( + model: model, + pricingDate: pricingDate, + modelsDevCatalog: pricing.modelsDevCatalog, + modelsDevCacheRoot: pricing.modelsDevCacheRoot) + else { return nil } + + guard let callCount = self.validatedModelCallCount(for: usage) else { + if let threshold = resolvedPricing.thresholdTokens, + usage.inputTokens > threshold + { + return nil + } + return CostUsagePricing.codexCostUSD( + pricing: resolvedPricing, + inputTokens: usage.inputTokens, + cachedInputTokens: usage.cachedReadTokens, + cacheWriteInputTokens: usage.cacheCreationTokens, + outputTokens: usage.outputTokens) + } + + // Even splitting is intentionally an approximation: context normally grows within a turn, + // so mean per-call inputs under-tier later calls. In a measured 27-turn sample this was about + // 4% below the vendor tick proxy overall and 26% low on one 28-call turn. Vendor ticks still + // do not drive displayed cost; the split is retained because aggregate tiering overstates it. + return self.syntheticCallGroups( + usage: usage, + callCount: callCount, + pricing: resolvedPricing) + .reduce(0) { partial, group in + partial + CostUsagePricing.codexCostUSD( + pricing: self.fixedTierPricing(resolvedPricing, usesLongContextRates: group.isLongContext), + inputTokens: group.inputTokens, + cachedInputTokens: group.cachedReadTokens, + cacheWriteInputTokens: group.cacheCreationTokens, + outputTokens: group.outputTokens) + } + } + + private static func distributed(_ total: Int, index: Int, count: Int) -> Int { + let quotient = total / count + return quotient + (index < total % count ? 1 : 0) + } + + private struct SyntheticCallGroup { + let inputTokens: Int + let cachedReadTokens: Int + let cacheCreationTokens: Int + let outputTokens: Int + let isLongContext: Bool + } + + private static func validatedModelCallCount(for usage: GrokParsedTokenUsage) -> Int? { + guard let modelCalls = usage.modelCalls, + modelCalls > 0, + modelCalls <= usage.inputTokens, + modelCalls <= self.maximumValidatedModelCalls + else { return nil } + return modelCalls + } + + private static func requestCount(for usage: GrokParsedTokenUsage) -> Int { + self.validatedModelCallCount(for: usage) ?? 1 + } + + private static func syntheticCallGroups( + usage: GrokParsedTokenUsage, + callCount: Int, + pricing: CostUsagePricing.CodexPricing) -> [SyntheticCallGroup] + { + let largerInputCallCount = usage.inputTokens % callCount + let baseInput = usage.inputTokens / callCount + let threshold = pricing.thresholdTokens + var ranges: [(range: Range, isLongContext: Bool)] = [] + if largerInputCallCount > 0 { + ranges.append(( + 0.. $0 } ?? false)) + } + if largerInputCallCount < callCount { + ranges.append(( + largerInputCallCount.. $0 } ?? false)) + } + return ranges.map { group in + let effectiveInput = self.effectiveInputTotals( + inputTokens: usage.inputTokens, + cachedReadTokens: usage.cachedReadTokens, + cacheCreationTokens: usage.cacheCreationTokens, + callCount: callCount, + range: group.range) + return SyntheticCallGroup( + inputTokens: effectiveInput.input, + cachedReadTokens: effectiveInput.cachedRead, + cacheCreationTokens: effectiveInput.cacheCreation, + outputTokens: self.distributedTotal( + usage.outputTokens, + count: callCount, + range: group.range), + isLongContext: group.isLongContext) + } + } + + private static func effectiveInputTotals( + inputTokens: Int, + cachedReadTokens: Int, + cacheCreationTokens: Int, + callCount: Int, + range: Range) -> (input: Int, cachedRead: Int, cacheCreation: Int) + { + let boundaries = Set([ + range.lowerBound, + range.upperBound, + min(max(inputTokens % callCount, range.lowerBound), range.upperBound), + min(max(cachedReadTokens % callCount, range.lowerBound), range.upperBound), + min(max(cacheCreationTokens % callCount, range.lowerBound), range.upperBound), + ]).sorted() + var input = 0 + var cachedRead = 0 + var cacheCreation = 0 + for (lower, upper) in zip(boundaries, boundaries.dropFirst()) where lower < upper { + let count = upper - lower + let perCallInput = self.distributed(inputTokens, index: lower, count: callCount) + let perCallCachedRead = min( + self.distributed(cachedReadTokens, index: lower, count: callCount), + perCallInput) + let remainingInput = perCallInput - perCallCachedRead + let perCallCacheCreation = min( + self.distributed(cacheCreationTokens, index: lower, count: callCount), + remainingInput) + input += perCallInput * count + cachedRead += perCallCachedRead * count + cacheCreation += perCallCacheCreation * count + } + return (input, cachedRead, cacheCreation) + } + + private static func distributedTotal(_ total: Int, count: Int, range: Range) -> Int { + let quotient = total / count + let remainder = total % count + let extra = max(0, min(range.upperBound, remainder) - range.lowerBound) + return quotient * range.count + extra + } + + private static func fixedTierPricing( + _ pricing: CostUsagePricing.CodexPricing, + usesLongContextRates: Bool) -> CostUsagePricing.CodexPricing + { + guard usesLongContextRates else { + return CostUsagePricing.CodexPricing( + inputCostPerToken: pricing.inputCostPerToken, + outputCostPerToken: pricing.outputCostPerToken, + cacheReadInputCostPerToken: pricing.cacheReadInputCostPerToken, + displayLabel: pricing.displayLabel, + cacheWriteInputCostPerToken: pricing.cacheWriteInputCostPerToken) + } + let inputRate = pricing.inputCostPerTokenAboveThreshold ?? pricing.inputCostPerToken + return CostUsagePricing.CodexPricing( + inputCostPerToken: inputRate, + outputCostPerToken: pricing.outputCostPerTokenAboveThreshold ?? pricing.outputCostPerToken, + cacheReadInputCostPerToken: pricing.cacheReadInputCostPerTokenAboveThreshold + ?? pricing.cacheReadInputCostPerToken + ?? inputRate, + displayLabel: pricing.displayLabel, + cacheWriteInputCostPerToken: pricing.cacheWriteInputCostPerTokenAboveThreshold + ?? pricing.cacheWriteInputCostPerToken + ?? inputRate) + } + + private static func finalize(day: String, bucket: MutableDailyBucket) -> GrokLocalDailyBucket { + let models = self.sortedModels(bucket.modelCounts) + let breakdowns = models.compactMap { model -> CostUsageDailyReport.ModelBreakdown? in + guard let value = bucket.modelBreakdowns[model] else { return nil } + return CostUsageDailyReport.ModelBreakdown( + modelName: model, + costUSD: value.hasPricedCost ? value.costUSD : nil, + totalTokens: value.totalTokens, + requestCount: value.requestCount, + inputTokens: value.inputTokens, + outputTokens: value.outputTokens, + cacheReadTokens: value.cacheReadTokens, + cacheCreationTokens: value.cacheCreationTokens, + reasoningTokens: value.reasoningTokens) + } + return GrokLocalDailyBucket( + date: day, + inputTokens: bucket.inputTokens, + cacheReadTokens: bucket.cacheReadTokens, + cacheCreationTokens: bucket.cacheCreationTokens, + outputTokens: bucket.outputTokens, + reasoningTokens: bucket.reasoningTokens, + totalTokens: bucket.totalTokens, + sessionCount: bucket.sessionIDs.count, + requestCount: bucket.requestCount, + costUSD: bucket.hasPricedCost ? bucket.costUSD : nil, + models: models, + modelBreakdowns: breakdowns, + unpricedRequestCount: bucket.unpricedRequestCount, + estimatedRequestCount: bucket.estimatedRequestCount) + } + + private static func sortedModels(_ counts: [String: Int]) -> [String] { + counts.sorted { lhs, rhs in + lhs.value == rhs.value ? lhs.key < rhs.key : lhs.value > rhs.value + }.map(\.key) + } + static func dayKey(for date: Date, calendar: Calendar) -> String? { let components = calendar.dateComponents([.year, .month, .day], from: date) guard let year = components.year, let month = components.month, let day = components.day else { diff --git a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift index 0768322055..affd4176ba 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift @@ -87,8 +87,8 @@ public enum GrokProviderDescriptor { tokenCost: ProviderTokenCostConfig( supportsTokenCost: true, noDataMessage: { - "Grok token totals come from local ~/.grok/sessions logs. " - + "Subscription credits are not converted to dollars." + "Grok totals come from local Grok CLI session logs. " + + "Costs are public list-price estimates, not a bill." }), pace: ProviderPaceCapability( resetWindowPace: .custom { window, now in @@ -344,7 +344,9 @@ struct GrokWebFetchStrategy: ProviderFetchStrategy { } var localSummary: @Sendable ([String: String]) async throws -> GrokLocalSessionSummary? = { - try await GrokLocalSessionScanner.summarizeOffMainThread(env: $0) + try await GrokLocalSessionScanner.summarizeOffMainThread( + env: $0, + lookbackDays: GrokLocalSessionScanner.maximumLookbackDays) } var cliVersion: @Sendable ([String: String]) -> String? = { GrokStatusProbe.detectVersion(env: $0) } diff --git a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift index 373975b355..8bce6b2d99 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift @@ -67,7 +67,7 @@ public struct GrokUsageSnapshot: Sendable { secondary: nil, tertiary: nil, costUsage: self.localSummary?.toCostUsageTokenSnapshot( - historyDays: GrokLocalSessionScanner.defaultLookbackDays), + historyDays: GrokLocalSessionScanner.maximumLookbackDays), updatedAt: self.updatedAt, identity: identity) } @@ -123,7 +123,9 @@ public struct GrokStatusProbe: Sendable { } // Local fallback summary always succeeds (empty if no sessions yet). - let localSummary = try await GrokLocalSessionScanner.summarizeOffMainThread(env: env) + let localSummary = try await GrokLocalSessionScanner.summarizeOffMainThread( + env: env, + lookbackDays: GrokLocalSessionScanner.maximumLookbackDays) let cliVersion = Self.detectVersion(env: env) // `localSummary` is *not* currently projected into a visible RateWindow or diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 1980d63975..429ecfaee4 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -477,6 +477,7 @@ enum CostUsagePricing { "opencode", "opencode-free", "opencode-go", + "xai", ] private static let claudeModelsDevProviderID = "anthropic" @@ -503,6 +504,16 @@ enum CostUsagePricing { break } var targets = providerIDs.map { ($0, modelID) } + // `grok-build-0.1` does not end in `-build` and must remain an exact catalog identity. + if routeID == "xai", + modelID.hasPrefix("grok-"), + modelID.hasSuffix("-build") + { + let normalized = String(modelID.dropLast("-build".count)) + if normalized.count > "grok-".count { + targets.append((routeID, normalized)) + } + } if routeID == self.codexModelsDevProviderID { let normalized = self.normalizeCodexModel(modelID) if normalized != modelID { diff --git a/Tests/CodexBarTests/GrokCostUsagePricingResolutionTests.swift b/Tests/CodexBarTests/GrokCostUsagePricingResolutionTests.swift new file mode 100644 index 0000000000..b7abb7fa2d --- /dev/null +++ b/Tests/CodexBarTests/GrokCostUsagePricingResolutionTests.swift @@ -0,0 +1,59 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +extension GrokCostUsagePricingTests { + @Test + func `xai routes normalize response build suffix only after exact lookup`() throws { + let normalizedOnly = try Self.catalog() + let exactJSON = """ + { + "xai": { + "id": "xai", + "models": { + "grok-4.6-build": { + "id": "grok-4.6-build", + "cost": { "input": 7, "output": 14 } + }, + "grok-4.6": { + "id": "grok-4.6", + "cost": { "input": 2, "output": 6 } + } + } + } + } + """ + let exactCatalog = try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(exactJSON.utf8)) + + let normalized = CostUsagePricing.codexCostUSD( + model: "xai/grok-4.6-build", + inputTokens: 100, + cachedInputTokens: 0, + outputTokens: 0, + modelsDevCatalog: normalizedOnly) + let realBuild = CostUsagePricing.codexCostUSD( + model: "xai/grok-build-0.1", + inputTokens: 100, + cachedInputTokens: 0, + outputTokens: 0, + modelsDevCatalog: normalizedOnly) + let exact = CostUsagePricing.codexCostUSD( + model: "xai/grok-4.6-build", + inputTokens: 100, + cachedInputTokens: 0, + outputTokens: 0, + modelsDevCatalog: exactCatalog) + let bare = CostUsagePricing.codexCostUSD( + model: "grok-4.6-build", + inputTokens: 100, + cachedInputTokens: 0, + outputTokens: 0, + modelsDevCatalog: normalizedOnly) + + #expect(normalized == 100.0 * 2e-6) + #expect(realBuild == 100.0 * 10e-6) + #expect(exact == 100.0 * 7e-6) + #expect(bare == nil) + } +} diff --git a/Tests/CodexBarTests/GrokCostUsagePricingTests.swift b/Tests/CodexBarTests/GrokCostUsagePricingTests.swift new file mode 100644 index 0000000000..04865abf75 --- /dev/null +++ b/Tests/CodexBarTests/GrokCostUsagePricingTests.swift @@ -0,0 +1,332 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@Suite(.serialized) +struct GrokCostUsagePricingTests: GrokLocalSessionScannerTestSupport { + @Test + func `completed turn reports exact tokens and public list price`() throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let turnAt = try self.localDate(day: 20, hour: 12) + let now = turnAt.addingTimeInterval(600) + let usage = self.usage( + input: 1000, + output: 100, + cachedRead: 200, + cacheCreation: 50, + reasoning: 20, + modelCalls: 1, + modelUsage: [ + "grok-4.6-build": self.modelUsage( + input: 1000, + output: 100, + cachedRead: 200, + cacheCreation: 50, + reasoning: 20, + modelCalls: 1), + ]) + try self.writeUpdates( + [self.turn(timestamp: turnAt, usage: usage)], + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: now) + + let summary = try self.summarize(fixture: fixture, now: now) + let day = try #require(summary.daily.first) + let expectedCost = (750.0 * 2e-6) + (200.0 * 0.5e-6) + (50.0 * 2e-6) + (100.0 * 6e-6) + + #expect(day.inputTokens == 1000) + #expect(day.cacheReadTokens == 200) + #expect(day.cacheCreationTokens == 50) + #expect(day.outputTokens == 100) + #expect(day.reasoningTokens == 20) + #expect(day.totalTokens == 1100) + #expect(day.requestCount == 1) + #expect(day.estimatedRequestCount == 0) + #expect(abs((day.costUSD ?? 0) - expectedCost) < 0.000000000001) + + let snapshot = try #require(summary.toCostUsageTokenSnapshot(historyDays: 7)) + #expect(snapshot.sessionTokens == 1100) + #expect(abs((snapshot.sessionCostUSD ?? 0) - expectedCost) < 0.000000000001) + #expect(abs((snapshot.last30DaysCostUSD ?? 0) - expectedCost) < 0.000000000001) + #expect(snapshot.costProvenance == .listPriceEstimate) + #expect(snapshot.daily.first?.estimatedRequestCount == nil) + #expect(snapshot.daily.first?.coverageCounts.priced == 1) + #expect(snapshot.daily.first?.coverageCounts.estimated == 0) + } + + @Test + func `model call averages select tiers while reported remainders stay exact`() throws { + let turnAt = try self.localDate(day: 20, hour: 13) + let standardFixture = try self.makeFixture() + let longFixture = try self.makeFixture() + defer { + try? FileManager.default.removeItem(at: standardFixture.root) + try? FileManager.default.removeItem(at: longFixture.root) + } + let standardUsage = self.usage( + input: 300_001, + output: 17, + cachedRead: 13, + cacheCreation: 7, + reasoning: 5, + modelCalls: 10, + modelUsage: [ + "grok-4.6-build": self.modelUsage( + input: 300_001, + output: 17, + cachedRead: 13, + cacheCreation: 7, + reasoning: 5, + modelCalls: 10), + ]) + let longUsage = self.usage( + input: 300_001, + output: 17, + cachedRead: 13, + cacheCreation: 7, + reasoning: 5, + modelCalls: 1, + modelUsage: [ + "grok-4.6-build": self.modelUsage( + input: 300_001, + output: 17, + cachedRead: 13, + cacheCreation: 7, + reasoning: 5, + modelCalls: 1), + ]) + try self.writeUpdates( + [self.turn(timestamp: turnAt, usage: standardUsage)], + to: standardFixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: turnAt.addingTimeInterval(60)) + try self.writeUpdates( + [self.turn(timestamp: turnAt, usage: longUsage)], + to: longFixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: turnAt.addingTimeInterval(60)) + + let standard = try #require(self.summarize( + fixture: standardFixture, + now: turnAt.addingTimeInterval(120)).daily.first) + let long = try #require(self.summarize( + fixture: longFixture, + now: turnAt.addingTimeInterval(120)).daily.first) + + #expect(standard.inputTokens == 300_001) + #expect(long.inputTokens == 300_001) + #expect(standard.outputTokens == 17) + #expect(standard.cacheReadTokens == 13) + #expect(standard.cacheCreationTokens == 7) + #expect(standard.modelBreakdowns.first?.inputTokens == 300_001) + #expect(abs((standard.costUSD ?? 0) - self.expectedStandardCost( + input: 300_001, + output: 17, + cachedRead: 13, + cacheCreation: 7)) < 0.000000000001) + #expect(abs((long.costUSD ?? 0) - self.expectedLongContextCost( + input: 300_001, + output: 17, + cachedRead: 13, + cacheCreation: 7)) < 0.000000000001) + #expect((long.costUSD ?? 0) > (standard.costUSD ?? 0)) + } + + @Test + func `mean input just under threshold deliberately stays on standard pricing`() throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let turnAt = try self.localDate(day: 20, hour: 13, minute: 30) + let input = 5_441_612 + let output = 280 + let modelCalls = 28 + let usage = self.usage( + input: input, + output: output, + modelCalls: modelCalls, + modelUsage: [ + "grok-4.6-build": self.modelUsage( + input: input, + output: output, + modelCalls: modelCalls), + ]) + try self.writeUpdates( + [self.turn(timestamp: turnAt, usage: usage)], + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: turnAt.addingTimeInterval(60)) + + let day = try #require(self.summarize( + fixture: fixture, + now: turnAt.addingTimeInterval(120)).daily.first) + + #expect(input / modelCalls == 194_343) + #expect(abs((day.costUSD ?? 0) - self.expectedStandardCost( + input: input, + output: output, + cachedRead: 0, + cacheCreation: 0)) < 0.000000000001) + } + + @Test + func `two raw SKUs keep separate pricing and exact catalog identities`() throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let turnAt = try self.localDate(day: 20, hour: 14) + let usage = self.usage( + input: 300, + output: 30, + modelCalls: 2, + modelUsage: [ + "grok-4.6-build": self.modelUsage(input: 100, output: 10, modelCalls: 1), + "grok-build-0.1": self.modelUsage(input: 200, output: 20, modelCalls: 1), + ]) + try self.writeUpdates( + [self.turn(timestamp: turnAt, usage: usage)], + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: turnAt.addingTimeInterval(60)) + + let day = try #require(self.summarize( + fixture: fixture, + now: turnAt.addingTimeInterval(120)).daily.first) + let breakdowns = Dictionary(uniqueKeysWithValues: day.modelBreakdowns.map { ($0.modelName, $0) }) + let normalizedCost = (100.0 * 2e-6) + (10.0 * 6e-6) + let exactBuildCost = (200.0 * 10e-6) + (20.0 * 20e-6) + + #expect(Set(breakdowns.keys) == ["grok-4.6-build", "grok-build-0.1"]) + #expect(abs((breakdowns["grok-4.6-build"]?.costUSD ?? 0) - normalizedCost) < 0.000000000001) + #expect(abs((breakdowns["grok-build-0.1"]?.costUSD ?? 0) - exactBuildCost) < 0.000000000001) + #expect(abs((day.costUSD ?? 0) - normalizedCost - exactBuildCost) < 0.000000000001) + } + + @Test + func `missing model call split over threshold stays unpriced`() throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let turnAt = try self.localDate(day: 20, hour: 18) + let model = self.modelUsage(input: 300_000, output: 10, modelCalls: nil) + let usage = self.usage( + input: 300_000, + output: 10, + modelCalls: nil, + modelUsage: ["grok-4.6-build": model]) + try self.writeUpdates( + [self.turn(timestamp: turnAt, usage: usage)], + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: turnAt.addingTimeInterval(60)) + + let day = try #require(self.summarize( + fixture: fixture, + now: turnAt.addingTimeInterval(120)).daily.first) + + #expect(day.totalTokens == 300_010) + #expect(day.requestCount == 1) + #expect(day.costUSD == nil) + #expect(day.unpricedRequestCount == 1) + #expect(day.modelBreakdowns.first?.costUSD == nil) + } + + @Test + func `production nil catalog pricing reads only the injected models dev cache`() throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let turnAt = try self.localDate(day: 20, hour: 18, minute: 50) + let cacheRoot = fixture.root.appendingPathComponent("fixture-cache", isDirectory: true) + #expect(try ModelsDevCache.save(catalog: Self.catalog(), fetchedAt: turnAt, cacheRoot: cacheRoot)) + try self.writeUpdates( + [self.turn(timestamp: turnAt, usage: self.singleModelUsage(input: 1000, output: 100))], + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: turnAt.addingTimeInterval(60)) + + let summary = GrokLocalSessionScanner.summarize( + env: ["GROK_HOME": fixture.root.path], + lookbackDays: 7, + now: turnAt.addingTimeInterval(120), + modelsDevCacheRoot: cacheRoot) + let day = try #require(summary.daily.first) + + #expect(abs((day.costUSD ?? 0) - self.expectedStandardCost( + input: 1000, + output: 100, + cachedRead: 0, + cacheCreation: 0)) < 0.000000000001) + } + + @MainActor + @Test + func `retained Grok history narrows daily rows and recomputes window totals`() throws { + let scannedAt = try self.localDate(day: 20, hour: 19) + let calendar = Calendar.current + let recentAt = try #require(calendar.date(byAdding: .day, value: -10, to: scannedAt)) + let olderAt = try #require(calendar.date(byAdding: .day, value: -40, to: scannedAt)) + let recentDay = try #require(GrokLocalSessionScanner.dayKey(for: recentAt, calendar: calendar)) + let olderDay = try #require(GrokLocalSessionScanner.dayKey(for: olderAt, calendar: calendar)) + let summary = GrokLocalSessionSummary( + sessionCount: 2, + totalTokens: 150, + lastSessionAt: recentAt, + primaryModel: "grok-4.6-build", + models: ["grok-4.6-build"], + daily: [ + GrokLocalDailyBucket( + date: olderDay, + totalTokens: 100, + sessionCount: 1, + requestCount: 1, + costUSD: 1, + models: ["grok-4.6-build"]), + GrokLocalDailyBucket( + date: recentDay, + totalTokens: 50, + sessionCount: 1, + requestCount: 1, + costUSD: 0.5, + models: ["grok-4.6-build"]), + ], + scannedAt: scannedAt) + let full = try #require(summary.toCostUsageTokenSnapshot( + historyDays: GrokLocalSessionScanner.maximumLookbackDays)) + + let narrowed = full.narrowed(toHistoryDays: 30, calendar: calendar) + let maximum = full.narrowed( + toHistoryDays: GrokLocalSessionScanner.maximumLookbackDays, + calendar: calendar) + + #expect(narrowed.historyDays == 30) + #expect(narrowed.last30DaysTokens == 50) + #expect(narrowed.last30DaysCostUSD == 0.5) + #expect(narrowed.last30DaysRequests == 1) + #expect(narrowed.daily.map(\.date) == [recentDay]) + #expect(maximum.historyDays == 365) + #expect(maximum.last30DaysTokens == 150) + #expect(maximum.last30DaysCostUSD == 1.5) + #expect(maximum.daily.map(\.date) == [olderDay, recentDay]) + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: testSettingsStore(suiteName: "GrokLocalSessionScannerTests-narrowed"), + startupBehavior: .testing, + environmentBase: [:]) + let providerSnapshot = UsageSnapshot( + primary: nil, + secondary: nil, + costUsage: full, + updatedAt: scannedAt, + identity: nil) + let projected30 = store.tokenSnapshot( + fromProviderSnapshot: providerSnapshot, + provider: .grok, + historyDays: 30) + let projected365 = store.tokenSnapshot( + fromProviderSnapshot: providerSnapshot, + provider: .grok, + historyDays: 365) + + #expect(projected30?.historyDays == 30) + #expect(projected30?.last30DaysTokens == 50) + #expect(projected30?.daily.map(\.date) == [recentDay]) + #expect(projected365?.historyDays == 365) + #expect(projected365?.last30DaysTokens == 150) + #expect(projected365?.daily.map(\.date) == [olderDay, recentDay]) + } +} diff --git a/Tests/CodexBarTests/GrokLocalSessionScannerTestSupport.swift b/Tests/CodexBarTests/GrokLocalSessionScannerTestSupport.swift new file mode 100644 index 0000000000..fcf538b12c --- /dev/null +++ b/Tests/CodexBarTests/GrokLocalSessionScannerTestSupport.swift @@ -0,0 +1,203 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct GrokLocalSessionScannerFixture { + let root: URL + let session: URL +} + +protocol GrokLocalSessionScannerTestSupport {} + +extension GrokLocalSessionScannerTestSupport { + func makeFixture() throws -> GrokLocalSessionScannerFixture { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("grok-session-scan-\(UUID().uuidString)", isDirectory: true) + let session = root.appendingPathComponent( + "sessions/%2Ftmp%2Fdemo/session-a", + isDirectory: true) + try FileManager.default.createDirectory(at: session, withIntermediateDirectories: true) + return GrokLocalSessionScannerFixture(root: root, session: session) + } + + func summarize(fixture: GrokLocalSessionScannerFixture, now: Date) throws -> GrokLocalSessionSummary { + try GrokLocalSessionScanner.summarize( + env: ["GROK_HOME": fixture.root.path], + lookbackDays: 7, + now: now, + modelsDevCatalog: Self.catalog()) + } + + func localDate(day: Int, hour: Int, minute: Int = 0) throws -> Date { + try #require(Calendar.current.date(from: DateComponents( + year: 2026, + month: 8, + day: day, + hour: hour, + minute: minute))) + } + + func turn( + timestamp: Date, + usage: [String: Any], + method: String = "_x.ai/session/update") -> [String: Any] + { + [ + "timestamp": Int(timestamp.timeIntervalSince1970), + "method": method, + "params": [ + "sessionId": "fixture-session", + "update": [ + "sessionUpdate": "turn_completed", + "stop_reason": "end_turn", + "usage": usage, + ], + ], + ] + } + + func singleModelUsage(input: Int, output: Int) -> [String: Any] { + self.usage( + input: input, + output: output, + modelCalls: 1, + modelUsage: [ + "grok-4.6-build": self.modelUsage(input: input, output: output, modelCalls: 1), + ]) + } + + func usage( + input: Int, + output: Int, + cachedRead: Int = 0, + cacheCreation: Int = 0, + reasoning: Int = 0, + modelCalls: Int?, + modelUsage: [String: [String: Any]]) -> [String: Any] + { + var result: [String: Any] = [ + "inputTokens": input, + "outputTokens": output, + "totalTokens": input + output, + "cachedReadTokens": cachedRead, + "cacheCreationTokens": cacheCreation, + "reasoningTokens": reasoning, + "modelUsage": modelUsage, + "numTurns": modelCalls ?? 1, + "costUsdTicks": 999_999_999_999, + ] + if let modelCalls { + result["modelCalls"] = modelCalls + } + return result + } + + func modelUsage( + input: Int, + output: Int, + cachedRead: Int = 0, + cacheCreation: Int = 0, + reasoning: Int = 0, + modelCalls: Int?) -> [String: Any] + { + var result: [String: Any] = [ + "inputTokens": input, + "outputTokens": output, + "totalTokens": input + output, + "cachedReadTokens": cachedRead, + "cacheCreationTokens": cacheCreation, + "reasoningTokens": reasoning, + "costUsdTicks": 999_999_999_999, + ] + if let modelCalls { + result["modelCalls"] = modelCalls + } + return result + } + + func writeUpdates( + _ objects: [[String: Any]], + rawLines: [String] = [], + to url: URL, + modificationDate: Date) throws + { + let encoded = try objects.map { object -> String in + let data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + return try #require(String(data: data, encoding: .utf8)) + } + let contents = (encoded + rawLines).joined(separator: "\n") + "\n" + try Data(contents.utf8).write(to: url) + try FileManager.default.setAttributes([.modificationDate: modificationDate], ofItemAtPath: url.path) + } + + func writeSignals(model: String, tokens: Int, to url: URL, modificationDate: Date) throws { + let payload: [String: Any] = [ + "contextTokensUsed": tokens, + "totalTokensBeforeCompaction": tokens, + "primaryModelId": model, + "modelsUsed": [model], + ] + try JSONSerialization.data(withJSONObject: payload).write(to: url) + try FileManager.default.setAttributes([.modificationDate: modificationDate], ofItemAtPath: url.path) + } + + func expectedStandardCost( + input: Int, + output: Int, + cachedRead: Int, + cacheCreation: Int) -> Double + { + let uncached = input - cachedRead - cacheCreation + return (Double(uncached) * 2e-6) + + (Double(cachedRead) * 0.5e-6) + + (Double(cacheCreation) * 2e-6) + + (Double(output) * 6e-6) + } + + func expectedLongContextCost( + input: Int, + output: Int, + cachedRead: Int, + cacheCreation: Int) -> Double + { + let uncached = input - cachedRead - cacheCreation + return (Double(uncached) * 4e-6) + + (Double(cachedRead) * 1e-6) + + (Double(cacheCreation) * 4e-6) + + (Double(output) * 12e-6) + } + + static func catalog() throws -> ModelsDevCatalog { + let json = """ + { + "xai": { + "id": "xai", + "models": { + "grok-4.6": { + "id": "grok-4.6", + "cost": { + "input": 2, + "output": 6, + "cache_read": 0.5, + "context_over_200k": { + "input": 4, + "output": 12, + "cache_read": 1 + } + } + }, + "grok-build-0.1": { + "id": "grok-build-0.1", + "cost": { + "input": 10, + "output": 20, + "cache_read": 2 + } + } + } + } + } + """ + return try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8)) + } +} diff --git a/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift b/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift index 94f09a72d6..f46ec96777 100644 --- a/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift +++ b/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift @@ -1,101 +1,429 @@ import Foundation import Testing +@testable import CodexBar @testable import CodexBarCore -struct GrokLocalSessionScannerTests { +@Suite(.serialized) +struct GrokLocalSessionScannerTests: GrokLocalSessionScannerTestSupport { @Test - func `daily buckets stay local and never invent dollars`() throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("grok-session-scan-\(UUID().uuidString)", isDirectory: true) - let cwd = root.appendingPathComponent("sessions/%2Ftmp%2Fdemo", isDirectory: true) - let first = cwd.appendingPathComponent("session-a", isDirectory: true) - let second = cwd.appendingPathComponent("session-b", isDirectory: true) - try FileManager.default.createDirectory(at: first, withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: second, withIntermediateDirectories: true) + func `line timestamps split one session across local midnight`() throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let beforeMidnight = try self.localDate(day: 20, hour: 23, minute: 59) + let afterMidnight = try self.localDate(day: 21, hour: 0, minute: 1) + let firstUsage = self.singleModelUsage(input: 100, output: 10) + let secondUsage = self.singleModelUsage(input: 200, output: 20) + try self.writeUpdates( + [ + self.turn(timestamp: beforeMidnight, usage: firstUsage, method: "_x.ai/session/update"), + self.turn(timestamp: afterMidnight, usage: secondUsage, method: "session/update"), + ], + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: afterMidnight.addingTimeInterval(60)) - let calendar = Calendar.current - let newer = Date(timeIntervalSince1970: 1_787_079_600) - let older = try #require(calendar.date(byAdding: .day, value: -1, to: newer)) - try self.writeSignals( - at: first.appendingPathComponent("signals.json"), - tokens: 100, - model: "grok-4.6", - date: older) - try self.writeSignals( - at: second.appendingPathComponent("signals.json"), - tokens: 250, - model: "grok-4.6", - date: newer) + let summary = try self.summarize(fixture: fixture, now: afterMidnight.addingTimeInterval(120)) - let summary = GrokLocalSessionScanner.summarize( - env: ["GROK_HOME": root.path], - lookbackDays: 7, - now: newer) - #expect(summary.sessionCount == 2) - #expect(summary.totalTokens == 350) - #expect(summary.daily.map(\.totalTokens) == [100, 250]) + #expect(summary.sessionCount == 1) + #expect(summary.daily.map(\.totalTokens) == [110, 220]) #expect(summary.daily.map(\.sessionCount) == [1, 1]) #expect(Set(summary.daily.map(\.date)).count == 2) + #expect(summary.lastSessionAt == afterMidnight) + } - let snapshot = try #require(summary.toCostUsageTokenSnapshot(historyDays: 7)) - #expect(snapshot.last30DaysTokens == 350) - #expect(snapshot.last30DaysCostUSD == nil) - #expect(snapshot.daily.allSatisfy { $0.costUSD == nil }) - #expect(snapshot.costProvenance == .unknown) - #expect(snapshot.sessionTokens == 250) + @Test + func `malformed completed lines do not discard valid turns`() throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let turnAt = try self.localDate(day: 20, hour: 15) + let valid = self.turn(timestamp: turnAt, usage: self.singleModelUsage(input: 123, output: 7)) + try self.writeUpdates( + [valid], + rawLines: ["{\"params\":{\"update\":{\"sessionUpdate\":\"turn_completed\"}}"], + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: turnAt.addingTimeInterval(60)) + + let summary = try self.summarize(fixture: fixture, now: turnAt.addingTimeInterval(120)) + + #expect(summary.sessionCount == 1) + #expect(summary.totalTokens == 130) + #expect(summary.daily.count == 1) } @Test - func `idle days do not reuse yesterday as today`() throws { + func `signals fallback contributes metadata only and updates take precedence`() throws { let root = FileManager.default.temporaryDirectory - .appendingPathComponent("grok-session-idle-\(UUID().uuidString)", isDirectory: true) - let session = root.appendingPathComponent("sessions/%2Ftmp%2Fdemo/session-a", isDirectory: true) - try FileManager.default.createDirectory(at: session, withIntermediateDirectories: true) - let calendar = Calendar.current - let yesterday = Date(timeIntervalSince1970: 1_787_079_600) - let today = try #require(calendar.date(byAdding: .day, value: 1, to: yesterday)) + .appendingPathComponent("grok-signals-fallback-\(UUID().uuidString)", isDirectory: true) + let sessions = root.appendingPathComponent("sessions/%2Ftmp%2Fdemo", isDirectory: true) + let signalsOnly = sessions.appendingPathComponent("signals-only", isDirectory: true) + let updatesPreferred = sessions.appendingPathComponent("updates-preferred", isDirectory: true) + try FileManager.default.createDirectory(at: signalsOnly, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: updatesPreferred, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let turnAt = try self.localDate(day: 20, hour: 16) + let now = turnAt.addingTimeInterval(120) + try self.writeSignals( + model: "grok-signals-only", + tokens: 999_999, + to: signalsOnly.appendingPathComponent("signals.json"), + modificationDate: now) try self.writeSignals( - at: session.appendingPathComponent("signals.json"), - tokens: 100, - model: "grok-4.6", - date: yesterday) - let summary = GrokLocalSessionScanner.summarize( + model: "grok-must-not-win", + tokens: 888_888, + to: updatesPreferred.appendingPathComponent("signals.json"), + modificationDate: now) + try self.writeUpdates( + [self.turn(timestamp: turnAt, usage: self.singleModelUsage(input: 90, output: 10))], + to: updatesPreferred.appendingPathComponent("updates.jsonl"), + modificationDate: now) + + let summary = try GrokLocalSessionScanner.summarize( env: ["GROK_HOME": root.path], lookbackDays: 7, - now: today) - let snapshot = try #require(summary.toCostUsageTokenSnapshot(historyDays: 7)) - #expect(snapshot.last30DaysTokens == 100) - #expect(snapshot.sessionTokens == nil) + now: now, + modelsDevCatalog: Self.catalog()) + + #expect(summary.sessionCount == 2) + #expect(summary.totalTokens == 100) + #expect(summary.models.contains("grok-signals-only")) + #expect(summary.models.contains("grok-4.6-build")) + #expect(!summary.models.contains("grok-must-not-win")) } @Test - func `empty homes do not publish a spend snapshot`() { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("grok-session-empty-\(UUID().uuidString)", isDirectory: true) - let summary = GrokLocalSessionScanner.summarize( - env: ["GROK_HOME": root.path], - lookbackDays: 7, - now: Date()) + func `daily buckets stay local and never invent dollars`() throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let now = try self.localDate(day: 20, hour: 16, minute: 30) + try self.writeSignals( + model: "grok-signals-only", + tokens: 999_999, + to: fixture.session.appendingPathComponent("signals.json"), + modificationDate: now) + + let summary = try self.summarize(fixture: fixture, now: now) + + #expect(summary.sessionCount == 1) + #expect(summary.totalTokens == 0) + #expect(summary.daily.isEmpty) #expect(summary.toCostUsageTokenSnapshot(historyDays: 7) == nil) } + @Test + func `empty and absent session trees preserve the empty summary`() throws { + let absent = FileManager.default.temporaryDirectory + .appendingPathComponent("grok-absent-\(UUID().uuidString)", isDirectory: true) + let empty = FileManager.default.temporaryDirectory + .appendingPathComponent("grok-empty-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory( + at: empty.appendingPathComponent("sessions", isDirectory: true), + withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: empty) } + + for root in [absent, empty] { + let summary = GrokLocalSessionScanner.summarize( + env: ["GROK_HOME": root.path], + lookbackDays: 7, + now: Date()) + #expect(summary.sessionCount == 0) + #expect(summary.totalTokens == 0) + #expect(summary.daily.isEmpty) + #expect(summary.toCostUsageTokenSnapshot(historyDays: 7) == nil) + } + } + + @Test + func `parse cache decodes unchanged files once and invalidates on file identity`() throws { + GrokLocalSessionScanner.resetParseCacheForTesting() + defer { GrokLocalSessionScanner.resetParseCacheForTesting() } + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let turnAt = try self.localDate(day: 20, hour: 17) + let updates = fixture.session.appendingPathComponent("updates.jsonl") + let first = self.turn(timestamp: turnAt, usage: self.singleModelUsage(input: 10, output: 1)) + let nonTurn = "{\"params\":{\"update\":{\"sessionUpdate\":\"tool_call_update\"}}}" + try self.writeUpdates( + [first], + rawLines: [nonTurn], + to: updates, + modificationDate: turnAt.addingTimeInterval(60)) + + _ = try self.summarize(fixture: fixture, now: turnAt.addingTimeInterval(120)) + let firstMetrics = GrokLocalSessionScanner.parseCacheMetricsForTesting() + _ = try self.summarize(fixture: fixture, now: turnAt.addingTimeInterval(120)) + let warmMetrics = GrokLocalSessionScanner.parseCacheMetricsForTesting() + let second = self.turn( + timestamp: turnAt.addingTimeInterval(1), + usage: self.singleModelUsage(input: 20, output: 2)) + try self.writeUpdates( + [first, second], + rawLines: [nonTurn], + to: updates, + modificationDate: turnAt.addingTimeInterval(90)) + let changed = try self.summarize(fixture: fixture, now: turnAt.addingTimeInterval(120)) + let changedMetrics = GrokLocalSessionScanner.parseCacheMetricsForTesting() + + #expect(firstMetrics == GrokLocalSessionParseCacheMetrics(fileDecodeCount: 1, jsonDecodeCount: 1)) + #expect(warmMetrics == firstMetrics) + #expect(changedMetrics == GrokLocalSessionParseCacheMetrics(fileDecodeCount: 2, jsonDecodeCount: 3)) + #expect(changed.totalTokens == 33) + } + + @Test + func `parse cache evicts deleted session files after the next scan`() throws { + GrokLocalSessionScanner.resetParseCacheForTesting() + defer { GrokLocalSessionScanner.resetParseCacheForTesting() } + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let turnAt = try self.localDate(day: 20, hour: 17, minute: 30) + let updates = fixture.session.appendingPathComponent("updates.jsonl") + try self.writeUpdates( + [self.turn(timestamp: turnAt, usage: self.singleModelUsage(input: 10, output: 1))], + to: updates, + modificationDate: turnAt.addingTimeInterval(60)) + + _ = try self.summarize(fixture: fixture, now: turnAt.addingTimeInterval(120)) + #expect(GrokLocalSessionScanner.parseCacheEntryCountForTesting() == 1) + + try FileManager.default.removeItem(at: updates) + _ = try self.summarize(fixture: fixture, now: turnAt.addingTimeInterval(120)) + + #expect(GrokLocalSessionScanner.parseCacheEntryCountForTesting() == 0) + } + + @Test + func `absurd model call count promptly falls back without iterating file content`() throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let turnAt = try self.localDate(day: 20, hour: 18, minute: 15) + let model = self.modelUsage(input: 300_000, output: 10, modelCalls: 900_000_000) + let usage = self.usage( + input: 300_000, + output: 10, + modelCalls: 900_000_000, + modelUsage: ["grok-4.6-build": model]) + try self.writeUpdates( + [self.turn(timestamp: turnAt, usage: usage)], + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: turnAt.addingTimeInterval(60)) + + let clock = ContinuousClock() + let startedAt = clock.now + let day = try #require(self.summarize( + fixture: fixture, + now: turnAt.addingTimeInterval(120)).daily.first) + let elapsed = startedAt.duration(to: clock.now) + + #expect(elapsed < .seconds(2)) + #expect(day.totalTokens == 300_010) + #expect(day.requestCount == 1) + #expect(day.costUSD == nil) + #expect(day.unpricedRequestCount == 1) + } + + @Test + func `explicit zero total tokens falls back to nonzero input and output sum`() throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let turnAt = try self.localDate(day: 20, hour: 18, minute: 30) + var usage = self.singleModelUsage(input: 100, output: 10) + usage["totalTokens"] = 0 + try self.writeUpdates( + [self.turn(timestamp: turnAt, usage: usage)], + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: turnAt.addingTimeInterval(60)) + + let day = try #require(self.summarize( + fixture: fixture, + now: turnAt.addingTimeInterval(120)).daily.first) + + #expect(day.totalTokens == 110) + #expect(day.inputTokens == 100) + #expect(day.outputTokens == 10) + } + + @Test + func `request coverage uses per SKU calls when model usage exists`() throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let turnAt = try self.localDate(day: 20, hour: 18, minute: 45) + let usage = self.usage( + input: 100, + output: 10, + modelCalls: 5, + modelUsage: ["grok-4.6-build": self.modelUsage(input: 100, output: 10, modelCalls: nil)]) + try self.writeUpdates( + [self.turn(timestamp: turnAt, usage: usage)], + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: turnAt.addingTimeInterval(60)) + + let summary = try self.summarize(fixture: fixture, now: turnAt.addingTimeInterval(120)) + let day = try #require(summary.daily.first) + let snapshot = try #require(summary.toCostUsageTokenSnapshot(historyDays: 7)) + + #expect(day.requestCount == 1) + #expect(day.unpricedRequestCount == 0) + #expect(snapshot.daily.first?.coverageCounts.priced == 1) + } + + @MainActor + @Test + func `supplied Grok provider snapshot performs zero additional JSON decodes`() throws { + GrokLocalSessionScanner.resetParseCacheForTesting() + defer { GrokLocalSessionScanner.resetParseCacheForTesting() } + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let turnAt = try self.localDate(day: 20, hour: 19) + try self.writeUpdates( + [self.turn(timestamp: turnAt, usage: self.singleModelUsage(input: 50, output: 5))], + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: turnAt.addingTimeInterval(60)) + let summary = try self.summarize(fixture: fixture, now: turnAt.addingTimeInterval(120)) + let projected = try #require(summary.toCostUsageTokenSnapshot( + historyDays: GrokLocalSessionScanner.maximumLookbackDays)) + let warmMetrics = GrokLocalSessionScanner.parseCacheMetricsForTesting() + let usage = UsageSnapshot( + primary: nil, + secondary: nil, + costUsage: projected, + updatedAt: turnAt, + identity: nil) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: testSettingsStore(suiteName: "GrokLocalSessionScannerTests-snapshot"), + startupBehavior: .testing, + environmentBase: ["GROK_HOME": fixture.root.path]) + + #expect(warmMetrics == GrokLocalSessionParseCacheMetrics(fileDecodeCount: 1, jsonDecodeCount: 1)) + let result = store.tokenSnapshot(fromProviderSnapshot: usage, provider: .grok, historyDays: 7) + + #expect(result?.historyDays == 7) + #expect(result?.daily == projected.daily) + #expect(result?.last30DaysTokens == projected.last30DaysTokens) + #expect(GrokLocalSessionScanner.parseCacheMetricsForTesting() == warmMetrics) + } + + @MainActor + @Test + func `concurrent Grok fallback callers coalesce into one maximum window scan`() async throws { + let settings = testSettingsStore(suiteName: "GrokLocalSessionScannerTests-coalesced") + let metadata = ProviderDescriptorRegistry.descriptor(for: .grok).metadata + settings.setProviderEnabled(provider: .grok, metadata: metadata, enabled: true) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: ["GROK_HOME": "/fixture/not-read"]) + let updatedAt = try self.localDate(day: 20, hour: 19, minute: 15) + let day = try #require(GrokLocalSessionScanner.dayKey(for: updatedAt, calendar: .current)) + let source = try #require(GrokLocalSessionSummary( + sessionCount: 1, + totalTokens: 77, + lastSessionAt: updatedAt, + primaryModel: "grok-4.6-build", + models: ["grok-4.6-build"], + daily: [GrokLocalDailyBucket( + date: day, + totalTokens: 77, + sessionCount: 1, + requestCount: 1, + costUSD: 0.1, + models: ["grok-4.6-build"])], + scannedAt: updatedAt) + .toCostUsageTokenSnapshot(historyDays: GrokLocalSessionScanner.maximumLookbackDays)) + var scanCount = 0 + var receivedLookbackDays: [Int] = [] + store._test_grokLocalTokenScannerOverride = { historyDays in + scanCount += 1 + receivedLookbackDays.append(historyDays) + try? await Task.sleep(nanoseconds: 50_000_000) + return source + } + + let first = Task { @MainActor in + await store.scanAndPublishGrokLocalTokenSnapshot(historyDays: 30) + } + let second = Task { @MainActor in + await store.scanAndPublishGrokLocalTokenSnapshot(historyDays: 365) + } + let firstResult = await first.value + let secondResult = await second.value + + #expect(scanCount == 1) + #expect(receivedLookbackDays == [365]) + #expect(firstResult?.historyDays == 30) + #expect(secondResult?.historyDays == 365) + #expect(store.tokenSnapshotPublicationForCurrentProviderConfig(for: .grok)?.snapshot?.historyDays == 365) + } + + @MainActor + @Test + func `missing remote snapshot scans and publishes local tokens then clears empty data`() async throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let turnAt = try self.localDate(day: 20, hour: 19, minute: 30) + let updates = fixture.session.appendingPathComponent("updates.jsonl") + try self.writeUpdates( + [self.turn(timestamp: turnAt, usage: self.singleModelUsage(input: 70, output: 7))], + to: updates, + modificationDate: turnAt.addingTimeInterval(60)) + let settings = testSettingsStore(suiteName: "GrokLocalSessionScannerTests-detached") + let metadata = ProviderDescriptorRegistry.descriptor(for: .grok).metadata + settings.setProviderEnabled(provider: .grok, metadata: metadata, enabled: true) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: ["GROK_HOME": fixture.root.path]) + + let snapshot = await store.scanAndPublishGrokLocalTokenSnapshot(historyDays: 7) + + #expect(snapshot?.last30DaysTokens == 77) + #expect(store.tokenSnapshotPublicationForCurrentProviderConfig(for: .grok)?.snapshot?.last30DaysTokens == 77) + + var fallbackScanCount = 0 + store._test_grokLocalTokenScannerOverride = { _ in + fallbackScanCount += 1 + return nil + } + store._test_providerFetchOutcomeOverride = { provider in + #expect(provider == .grok) + return ProviderFetchOutcome(result: .failure(URLError(.badServerResponse)), attempts: []) + } + await store.refreshProvider(.grok) + + #expect(fallbackScanCount == 0) + #expect(store.tokenSnapshotPublicationForCurrentProviderConfig(for: .grok)?.snapshot?.last30DaysTokens == 77) + + store._test_grokLocalTokenScannerOverride = nil + try FileManager.default.removeItem(at: updates) + store.clearTokenSnapshot(for: .grok) + let empty = await store.scanAndPublishGrokLocalTokenSnapshot(historyDays: 7) + + #expect(empty == nil) + #expect(store.tokenSnapshotPublicationForCurrentProviderConfig(for: .grok)?.snapshot == nil) + } + @Test func `local scan clock wins over a stale remote snapshot`() throws { let calendar = Calendar.current - let staleRemoteTime = Date(timeIntervalSince1970: 1_787_079_600) + let staleRemoteTime = try self.localDate(day: 20, hour: 10) let localScanTime = try #require(calendar.date(byAdding: .day, value: 1, to: staleRemoteTime)) let localDay = try #require(GrokLocalSessionScanner.dayKey(for: localScanTime, calendar: calendar)) let summary = GrokLocalSessionSummary( sessionCount: 1, totalTokens: 250, lastSessionAt: localScanTime, - primaryModel: "grok-4.6", - models: ["grok-4.6"], + primaryModel: "grok-4.6-build", + models: ["grok-4.6-build"], daily: [GrokLocalDailyBucket( date: localDay, totalTokens: 250, sessionCount: 1, - models: ["grok-4.6"])], + costUSD: 0.25, + models: ["grok-4.6-build"])], scannedAt: localScanTime) let remote = GrokUsageSnapshot( billing: nil, @@ -106,17 +434,7 @@ struct GrokLocalSessionScannerTests { let snapshot = try #require(remote.toUsageSnapshot().costUsage) #expect(snapshot.sessionTokens == 250) + #expect(snapshot.sessionCostUSD == 0.25) #expect(snapshot.updatedAt == localScanTime) } - - private func writeSignals(at url: URL, tokens: Int, model: String, date: Date) throws { - let payload: [String: Any] = [ - "contextTokensUsed": tokens, - "totalTokensBeforeCompaction": 0, - "primaryModelId": model, - "modelsUsed": [model], - ] - try JSONSerialization.data(withJSONObject: payload).write(to: url) - try FileManager.default.setAttributes([.modificationDate: date], ofItemAtPath: url.path) - } } diff --git a/Tests/CodexBarTests/GrokXAISpendCatalogTests.swift b/Tests/CodexBarTests/GrokXAISpendCatalogTests.swift index c58a1a4f09..b6cea4169f 100644 --- a/Tests/CodexBarTests/GrokXAISpendCatalogTests.swift +++ b/Tests/CodexBarTests/GrokXAISpendCatalogTests.swift @@ -10,6 +10,8 @@ struct GrokXAISpendCatalogTests { #expect(UsageStore.tokenCostRequiresProviderSnapshot(.xai)) #expect(ProviderDescriptorRegistry.descriptor(for: .grok).tokenCost.supportsTokenCost) #expect(ProviderDescriptorRegistry.descriptor(for: .xai).tokenCost.supportsTokenCost) + #expect(ProviderDescriptorRegistry.descriptor(for: .grok).tokenCost.noDataMessage() == + "Grok totals come from local Grok CLI session logs. Costs are public list-price estimates, not a bill.") } @Test(.enabled( diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 202f434e40..ff31b7ddc1 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -2441,8 +2441,17 @@ struct ProviderArchitectureGatekeeperTests { line: 292, anchor: "for provider in providers where provider != .codex {", expectedProviderIDs: ["codex", "grok"], - expectedReferenceCount: 7, - expectedReferenceFingerprint: ["codex@0", "grok@3", "grok@5", "grok@6", "grok@10", "grok@11", "grok@14"], + expectedReferenceCount: 8, + expectedReferenceFingerprint: [ + "codex@0", + "grok@3", + "grok@4", + "grok@7", + "grok@9", + "grok@16", + "grok@17", + "grok@20", + ], reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", @@ -2878,7 +2887,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+QuotaWarnings.swift", - line: 132, + line: 149, anchor: "let extraWindows = provider == .claude", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -2886,7 +2895,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+QuotaWarnings.swift", - line: 159, + line: 176, anchor: "guard provider == .claude else { return }", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3265,7 +3274,7 @@ struct ProviderArchitectureGatekeeperTests { line: 505, anchor: "case .openai:", expectedProviderIDs: ["grok", "mistral", "openai", "opencodego", "openrouter", "xai"], - expectedReferenceCount: 12, + expectedReferenceCount: 7, expectedReferenceFingerprint: [ "openai@0", "mistral@2", @@ -3279,6 +3288,7 @@ struct ProviderArchitectureGatekeeperTests { "opencodego@27", "openrouter@27", "xai@27", + "grok@28", ], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( @@ -3809,17 +3819,17 @@ struct ProviderArchitectureGatekeeperTests { path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift", line: 466, anchor: "static let codexModelsDevProviderID = \"openai\"", - expectedProviderIDs: ["deepseek", "openai", "opencode"], - expectedReferenceCount: 4, - expectedReferenceFingerprint: ["openai@0", "deepseek@7", "openai@10", "opencode@11"], + expectedProviderIDs: ["deepseek", "openai", "opencode", "xai"], + expectedReferenceCount: 5, + expectedReferenceFingerprint: ["openai@0", "deepseek@7", "openai@10", "opencode@11", "xai@14"], reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift", line: 501, anchor: "providerIDs.append(\"opencode\")", - expectedProviderIDs: ["opencode"], - expectedReferenceCount: 1, - expectedReferenceFingerprint: ["opencode@0"], + expectedProviderIDs: ["opencode", "xai"], + expectedReferenceCount: 2, + expectedReferenceFingerprint: ["opencode@0", "xai@6"], reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift", From 96af763b17bc9f656cbe16faeb3a7cd1b6a8d5e9 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Fri, 21 Aug 2026 21:15:25 -0700 Subject: [PATCH 02/34] feat(grok): count OpenCodex xAI traffic toward the Grok spend row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCodex sends inference straight to api.x.ai using the Grok account's OAuth credentials, so it burns the same SuperGrok subscription the Grok provider reports on. It only spawns the `grok` binary to refresh tokens, so those requests never reach ~/.grok/sessions and the local session scanner cannot see them — 1,435 requests on one real machine that CodexBar attributed to nothing. Route the `xai` provider prefix to the Grok subscription, the same way `openai` already routes to Codex. Like that mapping, this routes on the prefix and does not distinguish OAuth from API-key traffic. The `-build` suffix seen in the data is a responses-API protocol artifact, not a separate billing pool, so traffic is not split by it. Routing alone would have produced tokens with no dollars. The aggregator priced the bare `entry.model`, and a name without a route prefix is resolved against the `openai` provider — which is why `gpt-5.6-sol` prices today and `grok-4.6` resolved to `openai/grok-4.6` and missed. Qualify an unprefixed model with its provider before pricing. Codex rows are unaffected (the qualified name resolves to the same target), and providers outside the supported set keep returning nil. --- .../OpenCodexRouteDispatcher.swift | 2 + .../OpenCodexUsageAggregator.swift | 3 +- .../OpenCodexRouteDispatcherTests.swift | 15 ++ .../OpenCodexUsageFanOutTests.swift | 135 ++++++++++++++++++ docs/grok.md | 10 ++ 5 files changed, 164 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexRouteDispatcher.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexRouteDispatcher.swift index aa80374dcc..a20068eb3c 100644 --- a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexRouteDispatcher.swift +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexRouteDispatcher.swift @@ -12,6 +12,8 @@ public enum OpenCodexRouteDispatcher { switch provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { case "openai": .subscription(.codex) + case "xai": + .subscription(.grok) case "opencode-go": .subscription(.opencodego) case "kimi-coding", "kimi-for-coding": diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift index 1e7d6eb60b..4f11ece4b3 100644 --- a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift @@ -368,8 +368,9 @@ enum OpenCodexUsageAggregator { { return overlay } + let pricingModel = entry.model.contains("/") ? entry.model : "\(entry.provider)/\(entry.model)" return CostUsagePricing.codexCostUSD( - model: entry.model, + model: pricingModel, inputTokens: input, cachedInputTokens: cacheRead, outputTokens: output, diff --git a/Tests/CodexBarTests/OpenCodexRouteDispatcherTests.swift b/Tests/CodexBarTests/OpenCodexRouteDispatcherTests.swift index 234e7100d1..f359eda06c 100644 --- a/Tests/CodexBarTests/OpenCodexRouteDispatcherTests.swift +++ b/Tests/CodexBarTests/OpenCodexRouteDispatcherTests.swift @@ -5,10 +5,15 @@ import Testing struct OpenCodexRouteDispatcherTests { @Test(arguments: [ ("openai", OpenCodexRouteTarget.subscription(.codex)), + ("xai", OpenCodexRouteTarget.subscription(.grok)), + (" XAI\n", OpenCodexRouteTarget.subscription(.grok)), ("opencode-go", OpenCodexRouteTarget.subscription(.opencodego)), ("kimi-coding", OpenCodexRouteTarget.subscription(.kimi)), ("deepseek", OpenCodexRouteTarget.subscription(.deepseek)), ("opencode-free", OpenCodexRouteTarget.tokenOnly), + ("kimi", OpenCodexRouteTarget.unknown), + ("anthropic", OpenCodexRouteTarget.unknown), + ("unknown", OpenCodexRouteTarget.unknown), ("unknown-vendor", OpenCodexRouteTarget.unknown), ]) func `provider routes to the expected subscription target`( @@ -42,4 +47,14 @@ struct OpenCodexRouteDispatcherTests { provider: "opencode-go", modelName: "gpt-5.2") == .subscription(.opencodego)) } + + @Test + func `explicit xai model prefix routes to Grok`() { + #expect( + OpenCodexRouteDispatcher.route(modelName: "xai/grok-4.6") == .subscription(.grok)) + #expect( + OpenCodexRouteDispatcher.route( + provider: "openai", + modelName: " xai/grok-4.6 ") == .subscription(.grok)) + } } diff --git a/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift b/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift index 1d6f476610..879af4008e 100644 --- a/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift +++ b/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift @@ -29,6 +29,77 @@ struct OpenCodexUsageFanOutTests { #expect(snapshots[.codex]?.last30DaysTokens == 150) } + @Test func `snapshotsBySubscription keeps xai and openai tokens on their subscription rows`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let now = Date(timeIntervalSince1970: 1_787_270_400) + let entries = [ + OpenCodexUsageEntry( + requestID: "xai-1", + timestamp: now, + provider: "xai", + model: "grok-4.6", + usageStatus: .reported, + usage: OpenCodexTokenUsage(inputTokens: 100, outputTokens: 20, totalTokens: 120), + totalTokens: 120), + OpenCodexUsageEntry( + requestID: "xai-2", + timestamp: now, + provider: "xai", + model: "xai/grok-4.6", + usageStatus: .reported, + usage: OpenCodexTokenUsage(inputTokens: 60, outputTokens: 20, totalTokens: 80), + totalTokens: 80), + OpenCodexUsageEntry( + requestID: "openai-1", + timestamp: now, + provider: "openai", + model: "gpt-5.6-sol", + usageStatus: .reported, + usage: OpenCodexTokenUsage(inputTokens: 30, outputTokens: 10, totalTokens: 40), + totalTokens: 40), + ] + + let snapshots = OpenCodexUsageFanOut.snapshotsBySubscription( + entries: entries, + now: now, + historyDays: 7, + calendar: calendar) + + #expect(Set(snapshots.keys) == [.codex, .grok]) + #expect(snapshots[.grok]?.last30DaysTokens == 200) + #expect(snapshots[.codex]?.last30DaysTokens == 40) + } + + @Test func `bare xai model prices from the injected xai catalog`() throws { + let catalog = try Self.pricingCatalog() + let snapshot = try Self.pricingSnapshot(provider: "xai", model: "grok-4.6", catalog: catalog) + let cost = try #require(snapshot.daily.first?.costUSD) + + #expect(abs(cost - 0.0023) < 0.000000000001) + } + + @Test func `bare openai model keeps its pre qualification catalog price`() throws { + let catalog = try Self.pricingCatalog() + let snapshot = try Self.pricingSnapshot(provider: "openai", model: "gpt-5.6-sol", catalog: catalog) + let cost = try #require(snapshot.daily.first?.costUSD) + let expected = try #require(CostUsagePricing.codexCostUSD( + model: "gpt-5.6-sol", + inputTokens: 1000, + cachedInputTokens: 200, + outputTokens: 100, + modelsDevCatalog: catalog)) + + #expect(abs(expected - 0.00125) < 0.000000000001) + #expect(cost == expected) + } + + @Test func `bare kimi model stays unpriced with an injected kimi catalog`() throws { + let snapshot = try Self.pricingSnapshot(provider: "kimi", model: "k3[1m]", catalog: Self.pricingCatalog()) + + #expect(snapshot.daily.first?.costUSD == nil) + } + @Test func `snapshotsBySubscription routes opencode go spend into open code go`() throws { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) @@ -844,4 +915,68 @@ private enum OpenCodexUsageSnapshotReference { case (nil, nil): nil } } + + private static func pricingSnapshot( + provider: String, + model: String, + catalog: ModelsDevCatalog) throws -> CostUsageTokenSnapshot + { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let now = Date(timeIntervalSince1970: 1_787_270_400) + return OpenCodexUsageAggregator.snapshot( + entries: [ + OpenCodexUsageEntry( + requestID: "pricing-\(provider)", + timestamp: now, + provider: provider, + model: model, + usageStatus: .reported, + usage: OpenCodexTokenUsage( + inputTokens: 1000, + outputTokens: 100, + cacheReadInputTokens: 200, + totalTokens: 1100), + totalTokens: 1100), + ], + now: now, + historyDays: 7, + calendar: calendar, + modelsDevCatalog: catalog) + } + + private static func pricingCatalog() throws -> ModelsDevCatalog { + let json = """ + { + "xai": { + "id": "xai", + "models": { + "grok-4.6": { + "id": "grok-4.6", + "cost": { "input": 2, "output": 6, "cache_read": 0.5 } + } + } + }, + "openai": { + "id": "openai", + "models": { + "gpt-5.6-sol": { + "id": "gpt-5.6-sol", + "cost": { "input": 1, "output": 4, "cache_read": 0.25 } + } + } + }, + "kimi": { + "id": "kimi", + "models": { + "k3[1m]": { + "id": "k3[1m]", + "cost": { "input": 9, "output": 19 } + } + } + } + } + """ + return try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8)) + } } diff --git a/docs/grok.md b/docs/grok.md index 6d4b6dcf34..0e579476ec 100644 --- a/docs/grok.md +++ b/docs/grok.md @@ -159,6 +159,16 @@ The grok.com billing gRPC-web endpoint remains a best-effort fallback. relabel the result with the new account. Cookie usage stays separate from this captured account; local session scanning and CLI behavior are unchanged. +## OpenCodex usage + +OpenCodex traffic authenticated with Grok OAuth credentials burns the same Grok +subscription. When **Include OpenCodex usage logs** is enabled, entries carrying the +`xai` provider prefix appear on the Grok row in **Usage & Spend**, with dollars shown +as a public xAI list-price estimate. The Grok provider page continues to show local +token and spend data only from the native Grok CLI's own session logs. + +Like the existing `openai` → Codex mapping, this attribution routes only on the +OpenCodex provider prefix. It does not distinguish OAuth traffic from API-key traffic. ## JSON-RPC contract From 61d39843473bc93441cbd4ee60d00959b159cb06 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Fri, 21 Aug 2026 22:34:00 -0700 Subject: [PATCH 03/34] fix(grok): fetch the models.dev catalog on Grok-only installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grok resolved list prices straight out of the cached models.dev catalog, but nothing in its path ever fetched that catalog. The only fetch trigger is CostUsageFetcher.refreshPricingIfAllowed, which is gated to Codex and Claude — and Grok never reaches it at all, because its snapshot comes from the provider probe rather than the shared token-cost pipeline. On a machine where Codex or Claude is also enabled the cache is already there, so this is invisible. Enable only Grok and the file never appears: every price lookup returns nil and the Cost row shows tokens with no money, permanently. Request ModelsDevPricingPipeline.refreshIfNeeded from the Grok scan paths. It is safe to call repeatedly — it returns immediately unless the cache is stale and serialises through its own coordinator — and it is detached rather than awaited, matching how the Codex and Claude paths already treat it: pricing availability must never delay or fail a local scan, and the next refresh fills in the value. `summarize` stays synchronous and side-effect free; the refresh lives in a wrapper so the parse-cache behaviour and existing tests are untouched. Reported as P2 by the automated review on the pull request. --- Sources/CodexBar/UsageStore+TokenCost.swift | 2 +- .../Grok/GrokLocalSessionScanner.swift | 41 +++++++ .../Grok/GrokProviderDescriptor.swift | 2 +- .../Providers/Grok/GrokStatusProbe.swift | 2 +- .../GrokLocalSessionScannerTests.swift | 109 ++++++++++++++++++ 5 files changed, 153 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 53d88e2b19..43588647bb 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -552,7 +552,7 @@ extension UsageStore { snapshot = await scannerOverride(GrokLocalSessionScanner.maximumLookbackDays) } else { let scanTask = Task.detached(priority: .utility) { - GrokLocalSessionScanner.summarize( + await GrokLocalSessionScanner.summarizeRequestingPricingRefresh( env: environment, lookbackDays: GrokLocalSessionScanner.maximumLookbackDays) .toCostUsageTokenSnapshot(historyDays: GrokLocalSessionScanner.maximumLookbackDays) diff --git a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift index 4e63efdec6..5ca66dba7a 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift @@ -284,6 +284,47 @@ public enum GrokLocalSessionScanner { private static let parseCache = GrokLocalSessionParseCache() private static let turnCompletedNeedle = Data("turn_completed".utf8) + /// Request a background models.dev refresh, then scan using the currently cached catalog. + /// The refresh is deliberately detached so pricing availability cannot delay or fail the local scan. + public static func summarizeRequestingPricingRefresh( + env: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + lookbackDays: Int = defaultLookbackDays, + now: Date = .init()) async -> GrokLocalSessionSummary + { + await self.summarizeRequestingPricingRefresh( + env: env, + fileManager: fileManager, + lookbackDays: lookbackDays, + now: now, + modelsDevCacheRoot: nil) + { + await ModelsDevPricingPipeline.refreshIfNeeded(now: now) + } + } + + static func summarizeRequestingPricingRefresh( + env: [String: String], + fileManager: FileManager = .default, + lookbackDays: Int = defaultLookbackDays, + now: Date = .init(), + modelsDevCacheRoot: URL?, + requestPricingRefresh: @escaping @Sendable () async -> Void) async -> GrokLocalSessionSummary + { + Task.detached(priority: .utility) { + await requestPricingRefresh() + } + return self.summarize( + env: env, + fileManager: fileManager, + lookbackDays: lookbackDays, + now: now, + pricing: PricingContext( + modelsDevCatalog: nil, + modelsDevCacheRoot: modelsDevCacheRoot, + customPricing: .empty)) + } + /// Walk `~/.grok/sessions///updates.jsonl` and aggregate completed turns. public static func summarize( env: [String: String] = ProcessInfo.processInfo.environment, diff --git a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift index affd4176ba..8d0aae66b8 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift @@ -344,7 +344,7 @@ struct GrokWebFetchStrategy: ProviderFetchStrategy { } var localSummary: @Sendable ([String: String]) async throws -> GrokLocalSessionSummary? = { - try await GrokLocalSessionScanner.summarizeOffMainThread( + await GrokLocalSessionScanner.summarizeRequestingPricingRefresh( env: $0, lookbackDays: GrokLocalSessionScanner.maximumLookbackDays) } diff --git a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift index 8bce6b2d99..d2cd0c1078 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift @@ -123,7 +123,7 @@ public struct GrokStatusProbe: Sendable { } // Local fallback summary always succeeds (empty if no sessions yet). - let localSummary = try await GrokLocalSessionScanner.summarizeOffMainThread( + let localSummary = await GrokLocalSessionScanner.summarizeRequestingPricingRefresh( env: env, lookbackDays: GrokLocalSessionScanner.maximumLookbackDays) let cliVersion = Self.detectVersion(env: env) diff --git a/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift b/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift index f46ec96777..96b3670622 100644 --- a/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift +++ b/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift @@ -131,6 +131,46 @@ struct GrokLocalSessionScannerTests: GrokLocalSessionScannerTestSupport { } } + @Test + func `absent models dev cache requests a background refresh`() async throws { + let cacheRoot = try self.makeModelsDevCacheRoot() + defer { try? FileManager.default.removeItem(at: cacheRoot) } + + await self.expectPricingRefreshRequests( + cacheRoot: cacheRoot, + now: Date(timeIntervalSince1970: 100_000), + expectedRequests: 1) + } + + @Test + func `stale models dev cache requests a background refresh`() async throws { + let cacheRoot = try self.makeModelsDevCacheRoot() + defer { try? FileManager.default.removeItem(at: cacheRoot) } + let now = Date(timeIntervalSince1970: 100_000) + try ModelsDevCache.save( + catalog: Self.catalog(), + fetchedAt: now.addingTimeInterval(-ModelsDevCache.ttlSeconds - 1), + cacheRoot: cacheRoot) + + await self.expectPricingRefreshRequests( + cacheRoot: cacheRoot, + now: now, + expectedRequests: 1) + } + + @Test + func `fresh models dev cache skips the background refresh`() async throws { + let cacheRoot = try self.makeModelsDevCacheRoot() + defer { try? FileManager.default.removeItem(at: cacheRoot) } + let now = Date(timeIntervalSince1970: 100_000) + try ModelsDevCache.save(catalog: Self.catalog(), fetchedAt: now, cacheRoot: cacheRoot) + + await self.expectPricingRefreshRequests( + cacheRoot: cacheRoot, + now: now, + expectedRequests: 0) + } + @Test func `parse cache decodes unchanged files once and invalidates on file identity`() throws { GrokLocalSessionScanner.resetParseCacheForTesting() @@ -437,4 +477,73 @@ struct GrokLocalSessionScannerTests: GrokLocalSessionScannerTestSupport { #expect(snapshot.sessionCostUSD == 0.25) #expect(snapshot.updatedAt == localScanTime) } + + private func makeModelsDevCacheRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("grok-modelsdev-refresh-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + private func expectPricingRefreshRequests( + cacheRoot: URL, + now: Date, + expectedRequests: Int) async + { + let transport = GrokModelsDevTrackingTransport() + let completion = GrokPricingRefreshCompletion() + let summary = await GrokLocalSessionScanner.summarizeRequestingPricingRefresh( + env: ["GROK_HOME": cacheRoot.path], + lookbackDays: 7, + now: now, + modelsDevCacheRoot: cacheRoot) + { + await ModelsDevPricingPipeline.refreshIfNeeded( + now: now, + cacheRoot: cacheRoot, + client: ModelsDevClient(transport: transport)) + await completion.finish() + } + await completion.waitUntilFinished() + + #expect(summary.daily.isEmpty) + #expect(transport.calls == expectedRequests) + } +} + +private actor GrokPricingRefreshCompletion { + private var finished = false + private var waiters: [CheckedContinuation] = [] + + func finish() { + self.finished = true + let waiters = self.waiters + self.waiters.removeAll() + waiters.forEach { $0.resume() } + } + + func waitUntilFinished() async { + if self.finished { return } + await withCheckedContinuation { continuation in + self.waiters.append(continuation) + } + } +} + +private final class GrokModelsDevTrackingTransport: ModelsDevHTTPTransport, @unchecked Sendable { + private let lock = NSLock() + private var callCount = 0 + + var calls: Int { + self.lock.withLock { self.callCount } + } + + func data(for _: URLRequest) async throws -> (Data, URLResponse) { + self.lock.withLock { self.callCount += 1 } + throw GrokModelsDevTrackingError.failed + } +} + +private enum GrokModelsDevTrackingError: Error { + case failed } From 8fd69149b7b33deb46d64eac02e88750f2295dca Mon Sep 17 00:00:00 2001 From: olddonkey Date: Fri, 21 Aug 2026 22:53:00 -0700 Subject: [PATCH 04/34] test: show cost, provenance and priced-day coverage in the gated Grok proof MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The opt-in live proof scanned real sessions but printed tokens only, which cannot evidence the half of this change that is about money. It now also reports today's and the window's list-price cost, the provenance, the window actually used, and how many days carried a price versus tokens — so an all-unpriced result is visible in the output instead of reading as zero. Still skipped unless CODEXBAR_LIVE_GROK_CATALOG_PROOF=1. --- .../GrokXAISpendCatalogTests.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/Tests/CodexBarTests/GrokXAISpendCatalogTests.swift b/Tests/CodexBarTests/GrokXAISpendCatalogTests.swift index b6cea4169f..df94354c5f 100644 --- a/Tests/CodexBarTests/GrokXAISpendCatalogTests.swift +++ b/Tests/CodexBarTests/GrokXAISpendCatalogTests.swift @@ -25,14 +25,33 @@ struct GrokXAISpendCatalogTests { requestedDays: 30, now: summary.scannedAt) let grokRow = try #require(model.groups.flatMap(\.providers).first { $0.id == UsageProvider.grok.rawValue }) + let tokenDayCount = snapshot.daily.count { ($0.totalTokens ?? 0) > 0 } + let pricedDayCount = snapshot.daily.count { $0.costUSD != nil } #expect(model.availableSources.map(\.id) == [UsageProvider.grok.rawValue]) #expect(grokRow.totalTokens == snapshot.last30DaysTokens) #expect(model.tokenActivity.contains { $0.totalTokens != nil }) + #expect(snapshot.historyDays == SpendDashboardSource.scanDays) + #expect(snapshot.costProvenance == .listPriceEstimate) + #expect(pricedDayCount <= tokenDayCount) + if tokenDayCount > 0 { + if pricedDayCount > 0 { + let windowCostUSD = try #require(snapshot.last30DaysCostUSD) + #expect(windowCostUSD > 0) + } else { + #expect(snapshot.last30DaysCostUSD == nil) + } + } print("catalog_source=grok") print("today_tokens=\(snapshot.sessionTokens ?? 0)") print("last_30_days_tokens=\(grokRow.totalTokens ?? 0)") + print("today_cost_usd=\(snapshot.sessionCostUSD.map { String($0) } ?? "nil")") + print("window_cost_usd=\(snapshot.last30DaysCostUSD.map { String($0) } ?? "nil")") + print("cost_provenance=\(snapshot.costProvenance.rawValue)") + print("history_days=\(snapshot.historyDays)") + print("priced_days=\(pricedDayCount)") + print("token_days=\(tokenDayCount)") print("daily_buckets=\(snapshot.daily.count)") print("available_sources=\(model.availableSources.map(\.id).joined(separator: ","))") } From 06e11e0c19ee7205af59711441da8e4d4a34358b Mon Sep 17 00:00:00 2001 From: olddonkey Date: Sat, 22 Aug 2026 00:17:12 -0700 Subject: [PATCH 05/34] test: prove the Grok fallback survives repeated probe failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The regression guard drove a single failing refresh after a local publication existed. The defect it covers is specifically about the *second* failure: the first one publishes through the fallback scan, and only the next one arrives with a publication already in place — which is what used to hit the generic clear branch. Drive the failure twice and assert the row and the scan count both hold. --- Tests/CodexBarTests/GrokLocalSessionScannerTests.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift b/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift index 96b3670622..d2707ff21c 100644 --- a/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift +++ b/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift @@ -437,6 +437,11 @@ struct GrokLocalSessionScannerTests: GrokLocalSessionScannerTestSupport { #expect(fallbackScanCount == 0) #expect(store.tokenSnapshotPublicationForCurrentProviderConfig(for: .grok)?.snapshot?.last30DaysTokens == 77) + await store.refreshProvider(.grok) + + #expect(fallbackScanCount == 0) + #expect(store.tokenSnapshotPublicationForCurrentProviderConfig(for: .grok)?.snapshot?.last30DaysTokens == 77) + store._test_grokLocalTokenScannerOverride = nil try FileManager.default.removeItem(at: updates) store.clearTokenSnapshot(for: .grok) From 3ac0ac2d6310f85d5c6a40850455ca95fa68afe6 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Sat, 22 Aug 2026 01:22:39 -0700 Subject: [PATCH 06/34] fix(grok): attribute OpenCodex xAI usage only when it is OAuth-backed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routing every OpenCodex `xai` record to the Grok subscription is right for the case that motivated it — traffic authenticated with the user's Grok account, which is what makes it burn the SuperGrok quota. It is wrong for anyone using an xAI API key: their pay-as-you-go developer-platform spend gets folded into the subscription row, silently inflating it. CodexBar models that platform as its own xAI provider precisely to keep the two apart. The usage log carries no per-record credential evidence, so the decision has to come from the OpenCodex provider config, which records `authMode` per provider. Read it, and attribute to Grok only when that mode is OAuth; anything else is token-only spend that belongs to no tracked subscription. Fail closed: a missing or malformed config, no `xai` entry, or an absent `authMode` all count as no OAuth evidence and keep the records off the Grok row. The dispatcher stays a pure function — the set of OAuth-backed provider ids is threaded in from the caller rather than read at the routing site — and the gate applies only to `xai`, leaving the other routes exactly as they were. Also records why the Grok pricing refresh stays fire-and-forget: the parse cache holds parsed turns rather than prices, so the next scan reprices against the refreshed catalog, and plumbing completion back to republish was judged disproportionate to a delay Codex and Claude already share. Raised as P1 by the automated review; the owner chose verifiable attribution over prefix-only routing. --- .../SpendDashboardSource+OpenCodex.swift | 3 +- .../Grok/GrokLocalSessionScanner.swift | 4 + .../OpenCodexRouteDispatcher.swift | 38 +++--- .../OpenCodexUsage/OpenCodexUsageFanOut.swift | 4 +- .../OpenCodexUsage/OpenCodexUsageModels.swift | 67 ++++++++-- .../OpenCodexRouteDispatcherTests.swift | 117 +++++++++++++++++- .../OpenCodexUsageFanOutTests.swift | 87 ++++++++++--- docs/grok.md | 12 +- 8 files changed, 275 insertions(+), 57 deletions(-) diff --git a/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift b/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift index b4567bb4e7..1f414ac96a 100644 --- a/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift +++ b/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift @@ -42,7 +42,8 @@ extension SpendDashboardSource { entries: entries, now: request.now, historyDays: Self.scanDays, - calendar: request.configuration.bucketCalendar) + calendar: request.configuration.bucketCalendar, + oauthBackedProviderIDs: OpenCodexUsageLog.oauthBackedProviderIDs(environment: environment)) var merged = inputs.filter { $0.id != SpendDashboardModel.openCodexSourceID } var published = false diff --git a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift index 5ca66dba7a..6e3c68280d 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift @@ -311,6 +311,10 @@ public enum GrokLocalSessionScanner { modelsDevCacheRoot: URL?, requestPricingRefresh: @escaping @Sendable () async -> Void) async -> GrokLocalSessionSummary { + // This refresh is intentionally fire-and-forget, so the current scan uses whatever pricing the cache already + // holds. The parse cache stores parsed turns rather than prices, so every later scan reruns aggregation and + // pricing and will use the refreshed catalog. Plumbing completion back across the actor boundary to republish + // was considered and rejected as disproportionate to the one-refresh delay shared by Codex and Claude. Task.detached(priority: .utility) { await requestPricingRefresh() } diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexRouteDispatcher.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexRouteDispatcher.swift index a20068eb3c..19ffb4cd52 100644 --- a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexRouteDispatcher.swift +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexRouteDispatcher.swift @@ -7,34 +7,38 @@ public enum OpenCodexRouteTarget: Equatable, Sendable { } public enum OpenCodexRouteDispatcher { - public static func route(provider: String) -> OpenCodexRouteTarget { + public static func route( + provider: String, + oauthBackedProviderIDs: Set = []) -> OpenCodexRouteTarget + { // Provider-specific by design: OpenCodex provider prefixes map onto subscription rows or token-only spend. - switch provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + let providerID = provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + switch providerID { case "openai": - .subscription(.codex) + return .subscription(.codex) case "xai": - .subscription(.grok) + return oauthBackedProviderIDs.contains(providerID) ? .subscription(.grok) : .tokenOnly case "opencode-go": - .subscription(.opencodego) + return .subscription(.opencodego) case "kimi-coding", "kimi-for-coding": - .subscription(.kimi) + return .subscription(.kimi) case "deepseek": - .subscription(.deepseek) + return .subscription(.deepseek) case "opencode-free", "opencode": - .tokenOnly + return .tokenOnly default: - .unknown + return .unknown } } - public static func route(modelName: String) -> OpenCodexRouteTarget { + public static func route(modelName: String, oauthBackedProviderIDs: Set = []) -> OpenCodexRouteTarget { let trimmed = modelName.trimmingCharacters(in: .whitespacesAndNewlines) guard let slash = trimmed.firstIndex(of: "/") else { return .subscription(.codex) } let prefix = String(trimmed[.. Bool { @@ -44,14 +48,20 @@ public enum OpenCodexRouteDispatcher { return false } - public static func route(provider: String, modelName: String) -> OpenCodexRouteTarget { + public static func route( + provider: String, + modelName: String, + oauthBackedProviderIDs: Set = []) -> OpenCodexRouteTarget + { let trimmedModel = modelName.trimmingCharacters(in: .whitespacesAndNewlines) if trimmedModel.contains("/") { - let modelRoute = self.route(modelName: trimmedModel) + let modelRoute = self.route( + modelName: trimmedModel, + oauthBackedProviderIDs: oauthBackedProviderIDs) if modelRoute != .unknown { return modelRoute } } - return self.route(provider: provider) + return self.route(provider: provider, oauthBackedProviderIDs: oauthBackedProviderIDs) } } diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageFanOut.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageFanOut.swift index a060ff8df6..4f70690fe3 100644 --- a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageFanOut.swift +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageFanOut.swift @@ -6,13 +6,15 @@ public enum OpenCodexUsageFanOut { now: Date, historyDays: Int, calendar: Calendar, + oauthBackedProviderIDs: Set = [], customPricing: CostUsageCustomPricing = .empty) -> [UsageProvider: CostUsageTokenSnapshot] { var grouped: [UsageProvider: [OpenCodexUsageEntry]] = [:] for entry in entries { guard case let .subscription(provider) = OpenCodexRouteDispatcher.route( provider: entry.provider, - modelName: entry.model) + modelName: entry.model, + oauthBackedProviderIDs: oauthBackedProviderIDs) else { continue } diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageModels.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageModels.swift index b139b9394c..a411111f78 100644 --- a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageModels.swift +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageModels.swift @@ -123,20 +123,50 @@ public enum OpenCodexUsageLog { environment: [String: String] = ProcessInfo.processInfo.environment, homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL? { - if let override = environment["OPENCODEX_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines), - !override.isEmpty - { - return URL(fileURLWithPath: override, isDirectory: true) - .appendingPathComponent("usage.jsonl", isDirectory: false) - } - if Self.isRunningTests(environment) || Self.isRunningTests(ProcessInfo.processInfo.environment) { - return nil - } - return homeDirectory - .appendingPathComponent(".opencodex", isDirectory: true) + self.rootURL(environment: environment, homeDirectory: homeDirectory)? .appendingPathComponent("usage.jsonl", isDirectory: false) } + public static func configURL( + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL? + { + self.rootURL(environment: environment, homeDirectory: homeDirectory)? + .appendingPathComponent("config.json", isDirectory: false) + } + + public static func providerAuthModes( + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> [String: String] + { + guard let url = self.configURL(environment: environment, homeDirectory: homeDirectory), + let data = try? Data(contentsOf: url), + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let providers = root["providers"] as? [String: Any] + else { return [:] } + + var authModes: [String: String] = [:] + for (rawProviderID, rawConfiguration) in providers { + guard let configuration = rawConfiguration as? [String: Any], + let rawAuthMode = configuration["authMode"] as? String + else { continue } + let providerID = rawProviderID.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let authMode = rawAuthMode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !providerID.isEmpty, !authMode.isEmpty else { continue } + authModes[providerID] = authMode + } + return authModes + } + + public static func oauthBackedProviderIDs( + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> Set + { + Set(self.providerAuthModes(environment: environment, homeDirectory: homeDirectory).compactMap { entry in + entry.value == "oauth" ? entry.key : nil + }) + } + public static func cacheRoot( fileManager: FileManager = .default, codexBarCachesDirectory: URL? = nil) -> URL @@ -148,6 +178,21 @@ public enum OpenCodexUsageLog { return codexBarRoot.appendingPathComponent("opencodex-usage", isDirectory: true) } + private static func rootURL( + environment: [String: String], + homeDirectory: URL) -> URL? + { + if let override = environment["OPENCODEX_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + return URL(fileURLWithPath: override, isDirectory: true) + } + if Self.isRunningTests(environment) || Self.isRunningTests(ProcessInfo.processInfo.environment) { + return nil + } + return homeDirectory.appendingPathComponent(".opencodex", isDirectory: true) + } + private static func isRunningTests(_ environment: [String: String]) -> Bool { let keys = [ "XCTestConfigurationFilePath", diff --git a/Tests/CodexBarTests/OpenCodexRouteDispatcherTests.swift b/Tests/CodexBarTests/OpenCodexRouteDispatcherTests.swift index f359eda06c..b02e10744c 100644 --- a/Tests/CodexBarTests/OpenCodexRouteDispatcherTests.swift +++ b/Tests/CodexBarTests/OpenCodexRouteDispatcherTests.swift @@ -5,8 +5,8 @@ import Testing struct OpenCodexRouteDispatcherTests { @Test(arguments: [ ("openai", OpenCodexRouteTarget.subscription(.codex)), - ("xai", OpenCodexRouteTarget.subscription(.grok)), - (" XAI\n", OpenCodexRouteTarget.subscription(.grok)), + ("xai", OpenCodexRouteTarget.tokenOnly), + (" XAI\n", OpenCodexRouteTarget.tokenOnly), ("opencode-go", OpenCodexRouteTarget.subscription(.opencodego)), ("kimi-coding", OpenCodexRouteTarget.subscription(.kimi)), ("deepseek", OpenCodexRouteTarget.subscription(.deepseek)), @@ -49,12 +49,119 @@ struct OpenCodexRouteDispatcherTests { } @Test - func `explicit xai model prefix routes to Grok`() { + func `explicit xai model prefix routes to Grok only with OAuth evidence`() { #expect( - OpenCodexRouteDispatcher.route(modelName: "xai/grok-4.6") == .subscription(.grok)) + OpenCodexRouteDispatcher.route( + modelName: "xai/grok-4.6", + oauthBackedProviderIDs: ["xai"]) == .subscription(.grok)) #expect( OpenCodexRouteDispatcher.route( provider: "openai", - modelName: " xai/grok-4.6 ") == .subscription(.grok)) + modelName: " xai/grok-4.6 ", + oauthBackedProviderIDs: ["xai"]) == .subscription(.grok)) + #expect(OpenCodexRouteDispatcher.route(modelName: "xai/grok-4.6") == .tokenOnly) + } + + @Test + func `xai OAuth config routes to Grok`() throws { + let context = try Self.authContext(configJSON: """ + { + "providers": { + "xai": { "baseUrl": "https://api.x.ai/v1", "authMode": "oauth" } + } + } + """) + + #expect(context.authModes["xai"] == "oauth") + #expect(context.oauthBackedProviderIDs == ["xai"]) + #expect( + OpenCodexRouteDispatcher.route( + provider: "xai", + oauthBackedProviderIDs: context.oauthBackedProviderIDs) == .subscription(.grok)) + } + + @Test(arguments: ["apiKey", "forward", "oidc", "unknown"]) + func `xai non OAuth config stays token only`(authMode: String) throws { + let context = try Self.authContext(configJSON: """ + { "providers": { "xai": { "authMode": "\(authMode)" } } } + """) + + #expect(context.authModes["xai"] == authMode.lowercased()) + #expect(!context.oauthBackedProviderIDs.contains("xai")) + #expect( + OpenCodexRouteDispatcher.route( + provider: "xai", + oauthBackedProviderIDs: context.oauthBackedProviderIDs) == .tokenOnly) + } + + @Test + func `xai routing fails closed without readable complete OAuth config`() throws { + let cases: [(String, String?, Bool)] = [ + ("missing config", nil, false), + ("unreadable config", nil, true), + ("malformed JSON", "{not-json", false), + ("missing xai provider", #"{"providers":{"openai":{"authMode":"oauth"}}}"#, false), + ("missing auth mode", #"{"providers":{"xai":{"baseUrl":"https://api.x.ai/v1"}}}"#, false), + ] + + for (label, configJSON, makeConfigDirectory) in cases { + let context = try Self.authContext( + configJSON: configJSON, + makeConfigDirectory: makeConfigDirectory) + #expect( + OpenCodexRouteDispatcher.route( + provider: "xai", + oauthBackedProviderIDs: context.oauthBackedProviderIDs) == .tokenOnly, + "Fail-closed case: \(label)") + } + } + + @Test(arguments: [ + ("openai", OpenCodexRouteTarget.subscription(.codex)), + ("kimi-coding", OpenCodexRouteTarget.subscription(.kimi)), + ("deepseek", OpenCodexRouteTarget.subscription(.deepseek)), + ("opencode-go", OpenCodexRouteTarget.subscription(.opencodego)), + ]) + func `non xai subscription routes ignore xai auth state`( + provider: String, + expected: OpenCodexRouteTarget) + { + #expect(OpenCodexRouteDispatcher.route(provider: provider) == expected) + #expect( + OpenCodexRouteDispatcher.route( + provider: provider, + oauthBackedProviderIDs: ["xai"]) == expected) + } + + private struct AuthContext { + let authModes: [String: String] + let oauthBackedProviderIDs: Set + } + + private static func authContext( + configJSON: String?, + makeConfigDirectory: Bool = false) throws -> AuthContext + { + let fileManager = FileManager.default + let home = fileManager.temporaryDirectory + .appendingPathComponent("OpenCodexRouteDispatcherTests-\(UUID().uuidString)", isDirectory: true) + let openCodexHome = home.appendingPathComponent(".opencodex", isDirectory: true) + try fileManager.createDirectory(at: openCodexHome, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: home) } + + let configURL = openCodexHome.appendingPathComponent("config.json", isDirectory: false) + if makeConfigDirectory { + try fileManager.createDirectory(at: configURL, withIntermediateDirectories: false) + } else if let configJSON { + try configJSON.write(to: configURL, atomically: true, encoding: .utf8) + } + let environment = ["OPENCODEX_HOME": openCodexHome.path] + return AuthContext( + authModes: OpenCodexUsageLog.providerAuthModes( + environment: environment, + homeDirectory: home), + oauthBackedProviderIDs: OpenCodexUsageLog.oauthBackedProviderIDs( + environment: environment, + homeDirectory: home)) } } diff --git a/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift b/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift index 879af4008e..e4a9e172d9 100644 --- a/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift +++ b/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift @@ -29,27 +29,11 @@ struct OpenCodexUsageFanOutTests { #expect(snapshots[.codex]?.last30DaysTokens == 150) } - @Test func `snapshotsBySubscription keeps xai and openai tokens on their subscription rows`() throws { + @Test func `snapshotsBySubscription keeps OAuth xai and openai tokens on their subscription rows`() throws { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) let now = Date(timeIntervalSince1970: 1_787_270_400) - let entries = [ - OpenCodexUsageEntry( - requestID: "xai-1", - timestamp: now, - provider: "xai", - model: "grok-4.6", - usageStatus: .reported, - usage: OpenCodexTokenUsage(inputTokens: 100, outputTokens: 20, totalTokens: 120), - totalTokens: 120), - OpenCodexUsageEntry( - requestID: "xai-2", - timestamp: now, - provider: "xai", - model: "xai/grok-4.6", - usageStatus: .reported, - usage: OpenCodexTokenUsage(inputTokens: 60, outputTokens: 20, totalTokens: 80), - totalTokens: 80), + let entries = Self.xaiEntries(now: now) + [ OpenCodexUsageEntry( requestID: "openai-1", timestamp: now, @@ -59,18 +43,43 @@ struct OpenCodexUsageFanOutTests { usage: OpenCodexTokenUsage(inputTokens: 30, outputTokens: 10, totalTokens: 40), totalTokens: 40), ] + let oauthBackedProviderIDs = try Self.oauthBackedProviderIDs(authMode: "oauth") + let pricing = CostUsageCustomPricing( + entries: ["xai/grok-4.6": .init(input: 2, output: 6)], + fingerprint: "xai-oauth-test") let snapshots = OpenCodexUsageFanOut.snapshotsBySubscription( entries: entries, now: now, historyDays: 7, - calendar: calendar) + calendar: calendar, + oauthBackedProviderIDs: oauthBackedProviderIDs, + customPricing: pricing) #expect(Set(snapshots.keys) == [.codex, .grok]) #expect(snapshots[.grok]?.last30DaysTokens == 200) + let grokCost = try #require(snapshots[.grok]?.last30DaysCostUSD) + #expect(abs(grokCost - 0.00056) < 0.000000000001) #expect(snapshots[.codex]?.last30DaysTokens == 40) } + @Test func `snapshotsBySubscription leaves API key xai entries off the Grok row`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let now = Date(timeIntervalSince1970: 1_787_270_400) + let oauthBackedProviderIDs = try Self.oauthBackedProviderIDs(authMode: "apiKey") + + let snapshots = OpenCodexUsageFanOut.snapshotsBySubscription( + entries: Self.xaiEntries(now: now), + now: now, + historyDays: 7, + calendar: calendar, + oauthBackedProviderIDs: oauthBackedProviderIDs) + + #expect(snapshots[.grok] == nil) + #expect(snapshots.isEmpty) + } + @Test func `bare xai model prices from the injected xai catalog`() throws { let catalog = try Self.pricingCatalog() let snapshot = try Self.pricingSnapshot(provider: "xai", model: "grok-4.6", catalog: catalog) @@ -945,6 +954,46 @@ private enum OpenCodexUsageSnapshotReference { modelsDevCatalog: catalog) } + private static func xaiEntries(now: Date) -> [OpenCodexUsageEntry] { + [ + OpenCodexUsageEntry( + requestID: "xai-1", + timestamp: now, + provider: "xai", + model: "grok-4.6", + usageStatus: .reported, + usage: OpenCodexTokenUsage(inputTokens: 100, outputTokens: 20, totalTokens: 120), + totalTokens: 120), + OpenCodexUsageEntry( + requestID: "xai-2", + timestamp: now, + provider: "xai", + model: "xai/grok-4.6", + usageStatus: .reported, + usage: OpenCodexTokenUsage(inputTokens: 60, outputTokens: 20, totalTokens: 80), + totalTokens: 80), + ] + } + + private static func oauthBackedProviderIDs(authMode: String) throws -> Set { + let fileManager = FileManager.default + let home = fileManager.temporaryDirectory + .appendingPathComponent("OpenCodexUsageFanOutTests-\(UUID().uuidString)", isDirectory: true) + let openCodexHome = home.appendingPathComponent(".opencodex", isDirectory: true) + try fileManager.createDirectory(at: openCodexHome, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: home) } + try """ + { "providers": { "xai": { "authMode": "\(authMode)" } } } + """.write( + to: openCodexHome.appendingPathComponent("config.json", isDirectory: false), + atomically: true, + encoding: .utf8) + + return OpenCodexUsageLog.oauthBackedProviderIDs( + environment: ["OPENCODEX_HOME": openCodexHome.path], + homeDirectory: home) + } + private static func pricingCatalog() throws -> ModelsDevCatalog { let json = """ { diff --git a/docs/grok.md b/docs/grok.md index 0e579476ec..639e640cdb 100644 --- a/docs/grok.md +++ b/docs/grok.md @@ -163,12 +163,12 @@ The grok.com billing gRPC-web endpoint remains a best-effort fallback. OpenCodex traffic authenticated with Grok OAuth credentials burns the same Grok subscription. When **Include OpenCodex usage logs** is enabled, entries carrying the -`xai` provider prefix appear on the Grok row in **Usage & Spend**, with dollars shown -as a public xAI list-price estimate. The Grok provider page continues to show local -token and spend data only from the native Grok CLI's own session logs. - -Like the existing `openai` → Codex mapping, this attribution routes only on the -OpenCodex provider prefix. It does not distinguish OAuth traffic from API-key traffic. +`xai` provider prefix appear on the Grok row in **Usage & Spend** only when the +OpenCodex `xai` provider config has `authMode: "oauth"`, with dollars shown as a public +xAI list-price estimate. API-key traffic is deliberately left out of the Grok +subscription row because it belongs to xAI developer-platform billing. The Grok +provider page continues to show local token and spend data only from the native Grok +CLI's own session logs. ## JSON-RPC contract From a4d4902f29ed541882d72dfea176e8bcf0b2d6ac Mon Sep 17 00:00:00 2001 From: olddonkey Date: Sun, 23 Aug 2026 02:30:30 -0700 Subject: [PATCH 07/34] Address Grok usage review findings --- .../SpendDashboardSource+OpenCodex.swift | 3 +- .../Grok/GrokLocalSessionScanner.swift | 16 +-- .../OpenCodexRouteDispatcher.swift | 26 ++-- .../OpenCodexUsage/OpenCodexUsageFanOut.swift | 4 +- .../OpenCodexUsage/OpenCodexUsageModels.swift | 40 ------ .../GrokLocalSessionScannerTests.swift | 32 ++++- .../OpenCodexRouteDispatcherTests.swift | 114 +----------------- .../OpenCodexUsageFanOutTests.swift | 53 +------- docs/grok.md | 13 +- 9 files changed, 64 insertions(+), 237 deletions(-) diff --git a/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift b/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift index 1f414ac96a..b4567bb4e7 100644 --- a/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift +++ b/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift @@ -42,8 +42,7 @@ extension SpendDashboardSource { entries: entries, now: request.now, historyDays: Self.scanDays, - calendar: request.configuration.bucketCalendar, - oauthBackedProviderIDs: OpenCodexUsageLog.oauthBackedProviderIDs(environment: environment)) + calendar: request.configuration.bucketCalendar) var merged = inputs.filter { $0.id != SpendDashboardModel.openCodexSourceID } var published = false diff --git a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift index 6e3c68280d..f46bbb7af5 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift @@ -284,8 +284,9 @@ public enum GrokLocalSessionScanner { private static let parseCache = GrokLocalSessionParseCache() private static let turnCompletedNeedle = Data("turn_completed".utf8) - /// Request a background models.dev refresh, then scan using the currently cached catalog. - /// The refresh is deliberately detached so pricing availability cannot delay or fail the local scan. + /// Request a models.dev refresh, then scan using the best catalog available. + /// A stale catalog keeps pricing the scan while it refreshes in the background. With no catalog at all, the first + /// scan waits for the initial attempt so a successful refresh is reflected in the snapshot that callers publish. public static func summarizeRequestingPricingRefresh( env: [String: String] = ProcessInfo.processInfo.environment, fileManager: FileManager = .default, @@ -311,11 +312,12 @@ public enum GrokLocalSessionScanner { modelsDevCacheRoot: URL?, requestPricingRefresh: @escaping @Sendable () async -> Void) async -> GrokLocalSessionSummary { - // This refresh is intentionally fire-and-forget, so the current scan uses whatever pricing the cache already - // holds. The parse cache stores parsed turns rather than prices, so every later scan reruns aggregation and - // pricing and will use the refreshed catalog. Plumbing completion back across the actor boundary to republish - // was considered and rejected as disproportionate to the one-refresh delay shared by Codex and Claude. - Task.detached(priority: .utility) { + let hasCachedCatalog = ModelsDevCache.load(now: now, cacheRoot: modelsDevCacheRoot).artifact != nil + if hasCachedCatalog { + Task.detached(priority: .utility) { + await requestPricingRefresh() + } + } else { await requestPricingRefresh() } return self.summarize( diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexRouteDispatcher.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexRouteDispatcher.swift index 19ffb4cd52..fbc483cae0 100644 --- a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexRouteDispatcher.swift +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexRouteDispatcher.swift @@ -7,17 +7,17 @@ public enum OpenCodexRouteTarget: Equatable, Sendable { } public enum OpenCodexRouteDispatcher { - public static func route( - provider: String, - oauthBackedProviderIDs: Set = []) -> OpenCodexRouteTarget - { + public static func route(provider: String) -> OpenCodexRouteTarget { // Provider-specific by design: OpenCodex provider prefixes map onto subscription rows or token-only spend. let providerID = provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() switch providerID { case "openai": return .subscription(.codex) case "xai": - return oauthBackedProviderIDs.contains(providerID) ? .subscription(.grok) : .tokenOnly + // usage.jsonl does not retain the credential mode that produced a request. The current config cannot + // safely reclassify historical API-key and OAuth traffic, so xAI stays out of the Grok subscription row + // until the log carries record-time provenance. + return .tokenOnly case "opencode-go": return .subscription(.opencodego) case "kimi-coding", "kimi-for-coding": @@ -31,14 +31,14 @@ public enum OpenCodexRouteDispatcher { } } - public static func route(modelName: String, oauthBackedProviderIDs: Set = []) -> OpenCodexRouteTarget { + public static func route(modelName: String) -> OpenCodexRouteTarget { let trimmed = modelName.trimmingCharacters(in: .whitespacesAndNewlines) guard let slash = trimmed.firstIndex(of: "/") else { return .subscription(.codex) } let prefix = String(trimmed[.. Bool { @@ -48,20 +48,14 @@ public enum OpenCodexRouteDispatcher { return false } - public static func route( - provider: String, - modelName: String, - oauthBackedProviderIDs: Set = []) -> OpenCodexRouteTarget - { + public static func route(provider: String, modelName: String) -> OpenCodexRouteTarget { let trimmedModel = modelName.trimmingCharacters(in: .whitespacesAndNewlines) if trimmedModel.contains("/") { - let modelRoute = self.route( - modelName: trimmedModel, - oauthBackedProviderIDs: oauthBackedProviderIDs) + let modelRoute = self.route(modelName: trimmedModel) if modelRoute != .unknown { return modelRoute } } - return self.route(provider: provider, oauthBackedProviderIDs: oauthBackedProviderIDs) + return self.route(provider: provider) } } diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageFanOut.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageFanOut.swift index 4f70690fe3..a060ff8df6 100644 --- a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageFanOut.swift +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageFanOut.swift @@ -6,15 +6,13 @@ public enum OpenCodexUsageFanOut { now: Date, historyDays: Int, calendar: Calendar, - oauthBackedProviderIDs: Set = [], customPricing: CostUsageCustomPricing = .empty) -> [UsageProvider: CostUsageTokenSnapshot] { var grouped: [UsageProvider: [OpenCodexUsageEntry]] = [:] for entry in entries { guard case let .subscription(provider) = OpenCodexRouteDispatcher.route( provider: entry.provider, - modelName: entry.model, - oauthBackedProviderIDs: oauthBackedProviderIDs) + modelName: entry.model) else { continue } diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageModels.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageModels.swift index a411111f78..a432bcd80b 100644 --- a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageModels.swift +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageModels.swift @@ -127,46 +127,6 @@ public enum OpenCodexUsageLog { .appendingPathComponent("usage.jsonl", isDirectory: false) } - public static func configURL( - environment: [String: String] = ProcessInfo.processInfo.environment, - homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL? - { - self.rootURL(environment: environment, homeDirectory: homeDirectory)? - .appendingPathComponent("config.json", isDirectory: false) - } - - public static func providerAuthModes( - environment: [String: String] = ProcessInfo.processInfo.environment, - homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> [String: String] - { - guard let url = self.configURL(environment: environment, homeDirectory: homeDirectory), - let data = try? Data(contentsOf: url), - let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let providers = root["providers"] as? [String: Any] - else { return [:] } - - var authModes: [String: String] = [:] - for (rawProviderID, rawConfiguration) in providers { - guard let configuration = rawConfiguration as? [String: Any], - let rawAuthMode = configuration["authMode"] as? String - else { continue } - let providerID = rawProviderID.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let authMode = rawAuthMode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !providerID.isEmpty, !authMode.isEmpty else { continue } - authModes[providerID] = authMode - } - return authModes - } - - public static func oauthBackedProviderIDs( - environment: [String: String] = ProcessInfo.processInfo.environment, - homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> Set - { - Set(self.providerAuthModes(environment: environment, homeDirectory: homeDirectory).compactMap { entry in - entry.value == "oauth" ? entry.key : nil - }) - } - public static func cacheRoot( fileManager: FileManager = .default, codexBarCachesDirectory: URL? = nil) -> URL diff --git a/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift b/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift index d2707ff21c..511f2714f7 100644 --- a/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift +++ b/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift @@ -132,7 +132,7 @@ struct GrokLocalSessionScannerTests: GrokLocalSessionScannerTestSupport { } @Test - func `absent models dev cache requests a background refresh`() async throws { + func `absent models dev cache requests an initial refresh`() async throws { let cacheRoot = try self.makeModelsDevCacheRoot() defer { try? FileManager.default.removeItem(at: cacheRoot) } @@ -142,6 +142,36 @@ struct GrokLocalSessionScannerTests: GrokLocalSessionScannerTestSupport { expectedRequests: 1) } + @Test + func `successful initial catalog refresh prices the first summary`() async throws { + let fixture = try self.makeFixture() + let cacheRoot = try self.makeModelsDevCacheRoot() + defer { + try? FileManager.default.removeItem(at: fixture.root) + try? FileManager.default.removeItem(at: cacheRoot) + } + let turnAt = try self.localDate(day: 20, hour: 16, minute: 45) + let now = turnAt.addingTimeInterval(120) + try self.writeUpdates( + [self.turn(timestamp: turnAt, usage: self.singleModelUsage(input: 100, output: 10))], + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: turnAt.addingTimeInterval(60)) + let catalog = try Self.catalog() + + let summary = await GrokLocalSessionScanner.summarizeRequestingPricingRefresh( + env: ["GROK_HOME": fixture.root.path], + lookbackDays: 7, + now: now, + modelsDevCacheRoot: cacheRoot) + { + _ = ModelsDevCache.save(catalog: catalog, fetchedAt: now, cacheRoot: cacheRoot) + } + + #expect(summary.totalTokens == 110) + #expect(summary.daily.first?.costUSD != nil) + #expect(summary.daily.first?.unpricedRequestCount == 0) + } + @Test func `stale models dev cache requests a background refresh`() async throws { let cacheRoot = try self.makeModelsDevCacheRoot() diff --git a/Tests/CodexBarTests/OpenCodexRouteDispatcherTests.swift b/Tests/CodexBarTests/OpenCodexRouteDispatcherTests.swift index b02e10744c..1d2d5955a5 100644 --- a/Tests/CodexBarTests/OpenCodexRouteDispatcherTests.swift +++ b/Tests/CodexBarTests/OpenCodexRouteDispatcherTests.swift @@ -49,119 +49,11 @@ struct OpenCodexRouteDispatcherTests { } @Test - func `explicit xai model prefix routes to Grok only with OAuth evidence`() { - #expect( - OpenCodexRouteDispatcher.route( - modelName: "xai/grok-4.6", - oauthBackedProviderIDs: ["xai"]) == .subscription(.grok)) - #expect( - OpenCodexRouteDispatcher.route( - provider: "openai", - modelName: " xai/grok-4.6 ", - oauthBackedProviderIDs: ["xai"]) == .subscription(.grok)) + func `explicit xai model prefix stays token only without record time auth evidence`() { #expect(OpenCodexRouteDispatcher.route(modelName: "xai/grok-4.6") == .tokenOnly) - } - - @Test - func `xai OAuth config routes to Grok`() throws { - let context = try Self.authContext(configJSON: """ - { - "providers": { - "xai": { "baseUrl": "https://api.x.ai/v1", "authMode": "oauth" } - } - } - """) - - #expect(context.authModes["xai"] == "oauth") - #expect(context.oauthBackedProviderIDs == ["xai"]) - #expect( - OpenCodexRouteDispatcher.route( - provider: "xai", - oauthBackedProviderIDs: context.oauthBackedProviderIDs) == .subscription(.grok)) - } - - @Test(arguments: ["apiKey", "forward", "oidc", "unknown"]) - func `xai non OAuth config stays token only`(authMode: String) throws { - let context = try Self.authContext(configJSON: """ - { "providers": { "xai": { "authMode": "\(authMode)" } } } - """) - - #expect(context.authModes["xai"] == authMode.lowercased()) - #expect(!context.oauthBackedProviderIDs.contains("xai")) #expect( OpenCodexRouteDispatcher.route( - provider: "xai", - oauthBackedProviderIDs: context.oauthBackedProviderIDs) == .tokenOnly) - } - - @Test - func `xai routing fails closed without readable complete OAuth config`() throws { - let cases: [(String, String?, Bool)] = [ - ("missing config", nil, false), - ("unreadable config", nil, true), - ("malformed JSON", "{not-json", false), - ("missing xai provider", #"{"providers":{"openai":{"authMode":"oauth"}}}"#, false), - ("missing auth mode", #"{"providers":{"xai":{"baseUrl":"https://api.x.ai/v1"}}}"#, false), - ] - - for (label, configJSON, makeConfigDirectory) in cases { - let context = try Self.authContext( - configJSON: configJSON, - makeConfigDirectory: makeConfigDirectory) - #expect( - OpenCodexRouteDispatcher.route( - provider: "xai", - oauthBackedProviderIDs: context.oauthBackedProviderIDs) == .tokenOnly, - "Fail-closed case: \(label)") - } - } - - @Test(arguments: [ - ("openai", OpenCodexRouteTarget.subscription(.codex)), - ("kimi-coding", OpenCodexRouteTarget.subscription(.kimi)), - ("deepseek", OpenCodexRouteTarget.subscription(.deepseek)), - ("opencode-go", OpenCodexRouteTarget.subscription(.opencodego)), - ]) - func `non xai subscription routes ignore xai auth state`( - provider: String, - expected: OpenCodexRouteTarget) - { - #expect(OpenCodexRouteDispatcher.route(provider: provider) == expected) - #expect( - OpenCodexRouteDispatcher.route( - provider: provider, - oauthBackedProviderIDs: ["xai"]) == expected) - } - - private struct AuthContext { - let authModes: [String: String] - let oauthBackedProviderIDs: Set - } - - private static func authContext( - configJSON: String?, - makeConfigDirectory: Bool = false) throws -> AuthContext - { - let fileManager = FileManager.default - let home = fileManager.temporaryDirectory - .appendingPathComponent("OpenCodexRouteDispatcherTests-\(UUID().uuidString)", isDirectory: true) - let openCodexHome = home.appendingPathComponent(".opencodex", isDirectory: true) - try fileManager.createDirectory(at: openCodexHome, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: home) } - - let configURL = openCodexHome.appendingPathComponent("config.json", isDirectory: false) - if makeConfigDirectory { - try fileManager.createDirectory(at: configURL, withIntermediateDirectories: false) - } else if let configJSON { - try configJSON.write(to: configURL, atomically: true, encoding: .utf8) - } - let environment = ["OPENCODEX_HOME": openCodexHome.path] - return AuthContext( - authModes: OpenCodexUsageLog.providerAuthModes( - environment: environment, - homeDirectory: home), - oauthBackedProviderIDs: OpenCodexUsageLog.oauthBackedProviderIDs( - environment: environment, - homeDirectory: home)) + provider: "openai", + modelName: " xai/grok-4.6 ") == .tokenOnly) } } diff --git a/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift b/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift index e4a9e172d9..23e6da98a8 100644 --- a/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift +++ b/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift @@ -29,7 +29,7 @@ struct OpenCodexUsageFanOutTests { #expect(snapshots[.codex]?.last30DaysTokens == 150) } - @Test func `snapshotsBySubscription keeps OAuth xai and openai tokens on their subscription rows`() throws { + @Test func `snapshotsBySubscription never reclassifies xai history from current auth state`() throws { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) let now = Date(timeIntervalSince1970: 1_787_270_400) @@ -43,41 +43,15 @@ struct OpenCodexUsageFanOutTests { usage: OpenCodexTokenUsage(inputTokens: 30, outputTokens: 10, totalTokens: 40), totalTokens: 40), ] - let oauthBackedProviderIDs = try Self.oauthBackedProviderIDs(authMode: "oauth") - let pricing = CostUsageCustomPricing( - entries: ["xai/grok-4.6": .init(input: 2, output: 6)], - fingerprint: "xai-oauth-test") - let snapshots = OpenCodexUsageFanOut.snapshotsBySubscription( entries: entries, now: now, historyDays: 7, - calendar: calendar, - oauthBackedProviderIDs: oauthBackedProviderIDs, - customPricing: pricing) - - #expect(Set(snapshots.keys) == [.codex, .grok]) - #expect(snapshots[.grok]?.last30DaysTokens == 200) - let grokCost = try #require(snapshots[.grok]?.last30DaysCostUSD) - #expect(abs(grokCost - 0.00056) < 0.000000000001) - #expect(snapshots[.codex]?.last30DaysTokens == 40) - } - - @Test func `snapshotsBySubscription leaves API key xai entries off the Grok row`() throws { - var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) - let now = Date(timeIntervalSince1970: 1_787_270_400) - let oauthBackedProviderIDs = try Self.oauthBackedProviderIDs(authMode: "apiKey") - - let snapshots = OpenCodexUsageFanOut.snapshotsBySubscription( - entries: Self.xaiEntries(now: now), - now: now, - historyDays: 7, - calendar: calendar, - oauthBackedProviderIDs: oauthBackedProviderIDs) + calendar: calendar) + #expect(Set(snapshots.keys) == [.codex]) #expect(snapshots[.grok] == nil) - #expect(snapshots.isEmpty) + #expect(snapshots[.codex]?.last30DaysTokens == 40) } @Test func `bare xai model prices from the injected xai catalog`() throws { @@ -975,25 +949,6 @@ private enum OpenCodexUsageSnapshotReference { ] } - private static func oauthBackedProviderIDs(authMode: String) throws -> Set { - let fileManager = FileManager.default - let home = fileManager.temporaryDirectory - .appendingPathComponent("OpenCodexUsageFanOutTests-\(UUID().uuidString)", isDirectory: true) - let openCodexHome = home.appendingPathComponent(".opencodex", isDirectory: true) - try fileManager.createDirectory(at: openCodexHome, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: home) } - try """ - { "providers": { "xai": { "authMode": "\(authMode)" } } } - """.write( - to: openCodexHome.appendingPathComponent("config.json", isDirectory: false), - atomically: true, - encoding: .utf8) - - return OpenCodexUsageLog.oauthBackedProviderIDs( - environment: ["OPENCODEX_HOME": openCodexHome.path], - homeDirectory: home) - } - private static func pricingCatalog() throws -> ModelsDevCatalog { let json = """ { diff --git a/docs/grok.md b/docs/grok.md index 639e640cdb..d3ee215df3 100644 --- a/docs/grok.md +++ b/docs/grok.md @@ -161,14 +161,11 @@ The grok.com billing gRPC-web endpoint remains a best-effort fallback. ## OpenCodex usage -OpenCodex traffic authenticated with Grok OAuth credentials burns the same Grok -subscription. When **Include OpenCodex usage logs** is enabled, entries carrying the -`xai` provider prefix appear on the Grok row in **Usage & Spend** only when the -OpenCodex `xai` provider config has `authMode: "oauth"`, with dollars shown as a public -xAI list-price estimate. API-key traffic is deliberately left out of the Grok -subscription row because it belongs to xAI developer-platform billing. The Grok -provider page continues to show local token and spend data only from the native Grok -CLI's own session logs. +OpenCodex `xai` traffic is not merged into the Grok subscription row. The usage log +does not retain whether each request used Grok OAuth or an xAI API key, and the current +provider config cannot safely reclassify historical records. The Grok provider and +**Usage & Spend** therefore use only the native Grok CLI session logs until OpenCodex +records credential provenance at request time. ## JSON-RPC contract From 2869f78d56ae744fc5da613e8d35637930b1d48e Mon Sep 17 00:00:00 2001 From: olddonkey Date: Sun, 23 Aug 2026 03:16:29 -0700 Subject: [PATCH 08/34] Bound Grok session log scanning --- .../Grok/GrokLocalSessionScanner.swift | 388 ++++++++++++++---- .../GrokLocalSessionScannerTests.swift | 109 +++++ docs/grok.md | 63 ++- 3 files changed, 456 insertions(+), 104 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift index f46bbb7af5..623bc1d841 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift @@ -60,6 +60,7 @@ public struct GrokLocalSessionSummary: Sendable { public let models: [String] public let daily: [GrokLocalDailyBucket] public let scannedAt: Date + public let historyCoverageIsEstablished: Bool public init( sessionCount: Int, @@ -68,7 +69,8 @@ public struct GrokLocalSessionSummary: Sendable { primaryModel: String?, models: [String], daily: [GrokLocalDailyBucket] = [], - scannedAt: Date = .init()) + scannedAt: Date = .init(), + historyCoverageIsEstablished: Bool = true) { self.sessionCount = sessionCount self.totalTokens = totalTokens @@ -77,6 +79,7 @@ public struct GrokLocalSessionSummary: Sendable { self.models = models self.daily = daily self.scannedAt = scannedAt + self.historyCoverageIsEstablished = historyCoverageIsEstablished } /// Local tokens priced at public API list rates; this is an estimate, not a Grok bill. @@ -110,7 +113,7 @@ public struct GrokLocalSessionSummary: Sendable { last30DaysCostUSD: pricedDays.isEmpty ? nil : pricedDays.reduce(0, +), last30DaysRequests: self.daily.reduce(0) { $0 + $1.requestCount }, historyDays: historyDays, - historyCoverageIsEstablished: true, + historyCoverageIsEstablished: self.historyCoverageIsEstablished, costProvenance: .listPriceEstimate, daily: entries, updatedAt: self.scannedAt) @@ -122,6 +125,49 @@ struct GrokLocalSessionParseCacheMetrics: Sendable, Equatable { let jsonDecodeCount: Int } +struct GrokLocalSessionScanLimits: Sendable, Equatable { + static let production = Self( + maximumFileBytes: 64 * 1024 * 1024, + maximumLineBytes: 1024 * 1024, + maximumTurnsPerFile: 20000, + maximumSessions: 256, + maximumTotalBytes: 256 * 1024 * 1024, + maximumTotalTurns: 100_000) + + let maximumFileBytes: Int64 + let maximumLineBytes: Int + let maximumTurnsPerFile: Int + let maximumSessions: Int + let maximumTotalBytes: Int64 + let maximumTotalTurns: Int + + init( + maximumFileBytes: Int64, + maximumLineBytes: Int, + maximumTurnsPerFile: Int, + maximumSessions: Int = 256, + maximumTotalBytes: Int64 = 256 * 1024 * 1024, + maximumTotalTurns: Int = 100_000) + { + self.maximumFileBytes = max(1, maximumFileBytes) + self.maximumLineBytes = max(1, maximumLineBytes) + self.maximumTurnsPerFile = max(1, maximumTurnsPerFile) + self.maximumSessions = max(1, maximumSessions) + self.maximumTotalBytes = max(1, maximumTotalBytes) + self.maximumTotalTurns = max(1, maximumTotalTurns) + } + + func limitingFileBytes(to maximumFileBytes: Int64) -> Self { + Self( + maximumFileBytes: min(self.maximumFileBytes, maximumFileBytes), + maximumLineBytes: self.maximumLineBytes, + maximumTurnsPerFile: self.maximumTurnsPerFile, + maximumSessions: self.maximumSessions, + maximumTotalBytes: self.maximumTotalBytes, + maximumTotalTurns: self.maximumTotalTurns) + } +} + private struct GrokParsedTokenUsage: Sendable { let inputTokens: Int let outputTokens: Int @@ -138,32 +184,56 @@ private struct GrokParsedTurn: Sendable { let modelUsage: [String: GrokParsedTokenUsage] } +private struct GrokParsedTurnBatch: Sendable { + let turns: [GrokParsedTurn] + let historyCoverageIsEstablished: Bool +} + +private struct GrokTurnDecodeResult: Sendable { + let batch: GrokParsedTurnBatch + let jsonDecodeCount: Int + let cacheable: Bool +} + private final class GrokLocalSessionParseCache: @unchecked Sendable { - private struct Entry { + private struct Identity: Equatable { let size: Int let mtimeIntervalSince1970: TimeInterval - let turns: [GrokParsedTurn] + let limits: GrokLocalSessionScanLimits } + private struct Entry { + let identity: Identity + let batch: GrokParsedTurnBatch + var accessOrdinal: UInt64 + } + + private static let maximumEntries = 64 + private static let maximumCachedTurns = 50000 private let lock = NSLock() private var entries: [String: Entry] = [:] private var fileDecodeCount = 0 private var jsonDecodeCount = 0 + private var accessOrdinal: UInt64 = 0 func turns( path: String, size: Int, mtimeIntervalSince1970: TimeInterval, - decode: () -> (turns: [GrokParsedTurn], jsonDecodeCount: Int)) -> [GrokParsedTurn] + limits: GrokLocalSessionScanLimits, + decode: () -> GrokTurnDecodeResult) -> GrokParsedTurnBatch { + let identity = Identity( + size: size, + mtimeIntervalSince1970: mtimeIntervalSince1970, + limits: limits) self.lock.lock() - let observedIdentity = self.entries[path].map { ($0.size, $0.mtimeIntervalSince1970) } - if let entry = self.entries[path], - entry.size == size, - entry.mtimeIntervalSince1970 == mtimeIntervalSince1970 - { + let observedIdentity = self.entries[path]?.identity + if var entry = self.entries[path], entry.identity == identity { + entry.accessOrdinal = self.nextAccessOrdinal() + self.entries[path] = entry self.lock.unlock() - return entry.turns + return entry.batch } self.lock.unlock() @@ -172,34 +242,29 @@ private final class GrokLocalSessionParseCache: @unchecked Sendable { defer { self.lock.unlock() } self.fileDecodeCount += 1 self.jsonDecodeCount += decoded.jsonDecodeCount - if let entry = self.entries[path] { - if entry.size == size, - entry.mtimeIntervalSince1970 == mtimeIntervalSince1970 - { - return entry.turns - } - if observedIdentity?.0 != entry.size || - observedIdentity?.1 != entry.mtimeIntervalSince1970 - { - // A concurrent scan cached a different file identity while this decode was in flight. - // Return this scan's value without replacing the newer entry. - return decoded.turns - } - } else if observedIdentity != nil { - // A concurrent eviction happened while this decode was in flight. - return decoded.turns + guard decoded.cacheable else { return decoded.batch } + if var entry = self.entries[path], entry.identity == identity { + entry.accessOrdinal = self.nextAccessOrdinal() + self.entries[path] = entry + return entry.batch + } + if self.entries[path]?.identity != observedIdentity { + // A concurrent scan cached a different file identity, or evicted this one, while decoding. + return decoded.batch } self.entries[path] = Entry( - size: size, - mtimeIntervalSince1970: mtimeIntervalSince1970, - turns: decoded.turns) - return decoded.turns + identity: identity, + batch: decoded.batch, + accessOrdinal: self.nextAccessOrdinal()) + self.trimToLimits() + return decoded.batch } func retainEntries(at visitedPaths: Set) { self.lock.lock() defer { self.lock.unlock() } self.entries = self.entries.filter { visitedPaths.contains($0.key) } + self.trimToLimits() } func entryCount() -> Int { @@ -208,6 +273,12 @@ private final class GrokLocalSessionParseCache: @unchecked Sendable { return self.entries.count } + func cachedTurnCount() -> Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.entries.values.reduce(0) { $0 + $1.batch.turns.count } + } + func metrics() -> GrokLocalSessionParseCacheMetrics { self.lock.lock() defer { self.lock.unlock() } @@ -222,6 +293,23 @@ private final class GrokLocalSessionParseCache: @unchecked Sendable { self.entries.removeAll() self.fileDecodeCount = 0 self.jsonDecodeCount = 0 + self.accessOrdinal = 0 + } + + private func nextAccessOrdinal() -> UInt64 { + self.accessOrdinal &+= 1 + return self.accessOrdinal + } + + private func trimToLimits() { + var cachedTurns = self.entries.values.reduce(0) { $0 + $1.batch.turns.count } + while self.entries.count > Self.maximumEntries || cachedTurns > Self.maximumCachedTurns { + guard let victim = self.entries.min(by: { $0.value.accessOrdinal < $1.value.accessOrdinal }) else { + return + } + cachedTurns -= victim.value.batch.turns.count + self.entries.removeValue(forKey: victim.key) + } } } @@ -231,16 +319,16 @@ public enum GrokLocalSessionScanner { private static let maximumValidatedModelCalls = 10000 - private struct SessionFiles { - var updates: URL? - var signals: URL? - } - private struct FileIdentity { let size: Int let modificationDate: Date } + private struct RecentSessionSelection { + let paths: [String] + let historyCoverageIsEstablished: Bool + } + private struct MutableModelBreakdown { var inputTokens = 0 var cacheReadTokens = 0 @@ -356,7 +444,8 @@ public enum GrokLocalSessionScanner { now: Date = .init(), modelsDevCatalog: ModelsDevCatalog, modelsDevCacheRoot: URL? = nil, - customPricing: CostUsageCustomPricing? = .empty) -> GrokLocalSessionSummary + customPricing: CostUsageCustomPricing? = .empty, + scanLimits: GrokLocalSessionScanLimits = .production) -> GrokLocalSessionSummary { self.summarize( env: env, @@ -366,7 +455,8 @@ public enum GrokLocalSessionScanner { pricing: PricingContext( modelsDevCatalog: modelsDevCatalog, modelsDevCacheRoot: modelsDevCacheRoot, - customPricing: customPricing)) + customPricing: customPricing), + scanLimits: scanLimits) } static func summarize( @@ -393,56 +483,65 @@ public enum GrokLocalSessionScanner { fileManager: FileManager, lookbackDays: Int, now: Date, - pricing: PricingContext) -> GrokLocalSessionSummary + pricing: PricingContext, + scanLimits: GrokLocalSessionScanLimits = .production) -> GrokLocalSessionSummary { let root = GrokCredentialsStore.grokHomeURL(env: env, fileManager: fileManager) .appendingPathComponent("sessions", isDirectory: true) var visitedCachePaths: Set = [] defer { self.parseCache.retainEntries(at: visitedCachePaths) } - guard let rootEnum = fileManager.enumerator( - at: root, - includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey, .isDirectoryKey], - options: [.skipsHiddenFiles]) + let calendar = Calendar.current + let lookbackCutoff = calendar.date(byAdding: .day, value: -max(0, lookbackDays), to: now) ?? now + guard let sessionSelection = self.recentSessionPaths( + root: root, + fileManager: fileManager, + lookbackCutoff: lookbackCutoff, + maximumCount: scanLimits.maximumSessions) else { return self.emptySummary(now: now) } - var sessions: [String: SessionFiles] = [:] - while let url = rootEnum.nextObject() as? URL { - guard !Task.isCancelled else { return self.emptySummary(now: now) } - let name = url.lastPathComponent - guard name == "updates.jsonl" || name == "signals.json" else { continue } - let sessionPath = url.deletingLastPathComponent().path - if name == "updates.jsonl" { - sessions[sessionPath, default: SessionFiles()].updates = url - } else { - sessions[sessionPath, default: SessionFiles()].signals = url - } - } - - let calendar = Calendar.current - let lookbackCutoff = calendar.date(byAdding: .day, value: -max(0, lookbackDays), to: now) ?? now var sessionCount = 0 var lastSessionAt: Date? var aggregation = ScanAggregation() + var remainingTotalBytes = scanLimits.maximumTotalBytes + var remainingTotalTurns = scanLimits.maximumTotalTurns + var historyCoverageIsEstablished = sessionSelection.historyCoverageIsEstablished - for (sessionPath, files) in sessions { + for sessionPath in sessionSelection.paths { guard !Task.isCancelled else { return self.emptySummary(now: now) } + guard remainingTotalBytes > 0, remainingTotalTurns > 0 else { + historyCoverageIsEstablished = false + break + } + let sessionURL = URL(fileURLWithPath: sessionPath, isDirectory: true) var updatesYieldedCompletedTurns = false - if let updates = files.updates, - let identity = self.fileIdentity(for: updates), + let updates = sessionURL.appendingPathComponent("updates.jsonl") + if let identity = self.fileIdentity(for: updates), identity.modificationDate >= lookbackCutoff { + let fileByteLimit = min(scanLimits.maximumFileBytes, remainingTotalBytes) + let fileLimits = scanLimits.limitingFileBytes(to: fileByteLimit) + remainingTotalBytes -= min(Int64(max(0, identity.size)), fileByteLimit) visitedCachePaths.insert(updates.path) - let turns = self.parseCache.turns( + let parsed = self.parseCache.turns( path: updates.path, size: identity.size, - mtimeIntervalSince1970: identity.modificationDate.timeIntervalSince1970) + mtimeIntervalSince1970: identity.modificationDate.timeIntervalSince1970, + limits: fileLimits) { - self.decodeTurns(at: updates) + self.decodeTurns(at: updates, fileSize: identity.size, limits: fileLimits) } - updatesYieldedCompletedTurns = !turns.isEmpty - let currentTurns = turns.filter { $0.timestamp >= lookbackCutoff } + guard !Task.isCancelled else { return self.emptySummary(now: now) } + historyCoverageIsEstablished = historyCoverageIsEstablished + && parsed.historyCoverageIsEstablished + updatesYieldedCompletedTurns = !parsed.turns.isEmpty + var currentTurns = parsed.turns.filter { $0.timestamp >= lookbackCutoff } + if currentTurns.count > remainingTotalTurns { + currentTurns = Array(currentTurns.suffix(remainingTotalTurns)) + historyCoverageIsEstablished = false + } + remainingTotalTurns -= currentTurns.count if !currentTurns.isEmpty { sessionCount += 1 for turn in currentTurns { @@ -460,18 +559,26 @@ public enum GrokLocalSessionScanner { } } + let fallback = sessionURL.appendingPathComponent("signals.json") if !updatesYieldedCompletedTurns, - let fallback = files.signals, let identity = self.fileIdentity(for: fallback), - identity.modificationDate >= lookbackCutoff, - let metadataModels = self.readSignalsMetadata(at: fallback) + identity.modificationDate >= lookbackCutoff { - sessionCount += 1 - if identity.modificationDate > (lastSessionAt ?? Date.distantPast) { - lastSessionAt = identity.modificationDate - } - for model in metadataModels { - aggregation.modelCounts[model, default: 0] += 1 + let signalByteLimit = min(Int64(scanLimits.maximumLineBytes), remainingTotalBytes) + remainingTotalBytes -= min(Int64(max(0, identity.size)), signalByteLimit) + if Int64(identity.size) > signalByteLimit { + historyCoverageIsEstablished = false + } else if let metadataModels = self.readSignalsMetadata( + at: fallback, + maximumBytes: Int(signalByteLimit)) + { + sessionCount += 1 + if identity.modificationDate > (lastSessionAt ?? Date.distantPast) { + lastSessionAt = identity.modificationDate + } + for model in metadataModels { + aggregation.modelCounts[model, default: 0] += 1 + } } } } @@ -487,7 +594,8 @@ public enum GrokLocalSessionScanner { primaryModel: sortedModels.first, models: sortedModels, daily: buckets, - scannedAt: now) + scannedAt: now, + historyCoverageIsEstablished: historyCoverageIsEstablished) } public static func summarizeOffMainThread( @@ -515,6 +623,10 @@ public enum GrokLocalSessionScanner { self.parseCache.entryCount() } + static func parseCacheTurnCountForTesting() -> Int { + self.parseCache.cachedTurnCount() + } + private static func emptySummary(now: Date) -> GrokLocalSessionSummary { GrokLocalSessionSummary( sessionCount: 0, @@ -533,17 +645,115 @@ public enum GrokLocalSessionScanner { return FileIdentity(size: size, modificationDate: modificationDate) } - private static func decodeTurns(at url: URL) -> (turns: [GrokParsedTurn], jsonDecodeCount: Int) { - guard let data = try? Data(contentsOf: url) else { return ([], 0) } + private static func recentSessionPaths( + root: URL, + fileManager: FileManager, + lookbackCutoff: Date, + maximumCount: Int) -> RecentSessionSelection? + { + guard let rootEnum = fileManager.enumerator( + at: root, + includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey, .isDirectoryKey], + options: [.skipsHiddenFiles]) + else { return nil } + + var sessionModificationDates: [String: Date] = [:] + var historyCoverageIsEstablished = true + let trimThreshold = maximumCount > Int.max / 2 ? Int.max : maximumCount * 2 + while let url = rootEnum.nextObject() as? URL { + guard !Task.isCancelled else { return nil } + let name = url.lastPathComponent + guard name == "updates.jsonl" || name == "signals.json" else { continue } + guard let identity = self.fileIdentity(for: url), + identity.modificationDate >= lookbackCutoff + else { continue } + let sessionPath = url.deletingLastPathComponent().path + sessionModificationDates[sessionPath] = max( + sessionModificationDates[sessionPath] ?? .distantPast, + identity.modificationDate) + if sessionModificationDates.count > trimThreshold { + historyCoverageIsEstablished = false + self.trimRecentSessions(&sessionModificationDates, maximumCount: maximumCount) + } + } + if sessionModificationDates.count > maximumCount { + historyCoverageIsEstablished = false + self.trimRecentSessions(&sessionModificationDates, maximumCount: maximumCount) + } + let paths = sessionModificationDates.sorted { lhs, rhs in + lhs.value == rhs.value ? lhs.key < rhs.key : lhs.value > rhs.value + }.map(\.key) + return RecentSessionSelection( + paths: paths, + historyCoverageIsEstablished: historyCoverageIsEstablished) + } + + private static func trimRecentSessions( + _ sessions: inout [String: Date], + maximumCount: Int) + { + guard sessions.count > maximumCount else { return } + let recent = sessions.sorted { lhs, rhs in + lhs.value == rhs.value ? lhs.key < rhs.key : lhs.value > rhs.value + }.prefix(maximumCount) + sessions = Dictionary(uniqueKeysWithValues: recent.map { ($0.key, $0.value) }) + } + + private static func decodeTurns( + at url: URL, + fileSize: Int, + limits: GrokLocalSessionScanLimits) -> GrokTurnDecodeResult + { var turns: [GrokParsedTurn] = [] var jsonDecodeCount = 0 - for line in data.split(separator: 0x0A, omittingEmptySubsequences: true) { - guard line.range(of: self.turnCompletedNeedle) != nil else { continue } - jsonDecodeCount += 1 - guard let turn = self.decodeTurn(Data(line)) else { continue } - turns.append(turn) + let boundedFileSize = max(0, Int64(fileSize)) + let startOffset = max(0, boundedFileSize - limits.maximumFileBytes) + var historyCoverageIsEstablished = startOffset == 0 + var cacheable = true + var droppedTurns = false + let compactionThreshold = limits.maximumTurnsPerFile > Int.max / 2 + ? Int.max + : limits.maximumTurnsPerFile * 2 + + do { + try CostUsageJsonl.scan( + fileURL: url, + offset: startOffset, + maxLineBytes: limits.maximumLineBytes, + prefixBytes: limits.maximumLineBytes, + maxBytesToRead: limits.maximumFileBytes, + checkCancellation: { + if Task.isCancelled { throw CancellationError() } + }, + onLine: { line in + guard line.bytes.range(of: self.turnCompletedNeedle) != nil else { return } + jsonDecodeCount += 1 + guard !line.wasTruncated else { + historyCoverageIsEstablished = false + return + } + guard let turn = autoreleasepool(invoking: { self.decodeTurn(line.bytes) }) else { return } + turns.append(turn) + if turns.count > compactionThreshold { + turns.removeFirst(limits.maximumTurnsPerFile) + droppedTurns = true + } + }) + } catch { + historyCoverageIsEstablished = false + cacheable = false } - return (turns, jsonDecodeCount) + + if turns.count > limits.maximumTurnsPerFile { + turns.removeFirst(turns.count - limits.maximumTurnsPerFile) + droppedTurns = true + } + return GrokTurnDecodeResult( + batch: GrokParsedTurnBatch( + turns: turns, + historyCoverageIsEstablished: historyCoverageIsEstablished && !droppedTurns), + jsonDecodeCount: jsonDecodeCount, + cacheable: cacheable) } private static func decodeTurn(_ data: Data) -> GrokParsedTurn? { @@ -594,8 +804,14 @@ public enum GrokLocalSessionScanner { return nil } - private static func readSignalsMetadata(at url: URL) -> [String]? { - guard let data = try? Data(contentsOf: url), + private static func readSignalsMetadata(at url: URL, maximumBytes: Int) -> [String]? { + guard maximumBytes > 0, + let handle = try? FileHandle(forReadingFrom: url) + else { return nil } + defer { try? handle.close() } + let readLimit = maximumBytes == Int.max ? Int.max : maximumBytes + 1 + guard let data = try? handle.read(upToCount: readLimit), + data.count <= maximumBytes, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } var models: [String] = [] diff --git a/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift b/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift index 511f2714f7..b46fb8e850 100644 --- a/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift +++ b/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift @@ -260,6 +260,115 @@ struct GrokLocalSessionScannerTests: GrokLocalSessionScannerTestSupport { #expect(GrokLocalSessionScanner.parseCacheEntryCountForTesting() == 0) } + @Test + func `bounded tail scan retains only recent turns and marks history incomplete`() throws { + GrokLocalSessionScanner.resetParseCacheForTesting() + defer { GrokLocalSessionScanner.resetParseCacheForTesting() } + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let firstAt = try self.localDate(day: 20, hour: 17, minute: 40) + let secondAt = firstAt.addingTimeInterval(1) + let recentObjects = [ + self.turn(timestamp: firstAt, usage: self.singleModelUsage(input: 10, output: 1)), + self.turn(timestamp: secondAt, usage: self.singleModelUsage(input: 20, output: 2)), + ] + let recentLines = try recentObjects.map { object -> String in + let data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + return try #require(String(data: data, encoding: .utf8)) + } + let recentContents = recentLines.joined(separator: "\n") + "\n" + let contents = String(repeating: "x", count: 4096) + "\n" + recentContents + let updates = fixture.session.appendingPathComponent("updates.jsonl") + try Data(contents.utf8).write(to: updates) + try FileManager.default.setAttributes( + [.modificationDate: secondAt.addingTimeInterval(60)], + ofItemAtPath: updates.path) + let limits = GrokLocalSessionScanLimits( + maximumFileBytes: Int64(recentContents.utf8.count), + maximumLineBytes: 64 * 1024, + maximumTurnsPerFile: 1) + + let summary = try GrokLocalSessionScanner.summarize( + env: ["GROK_HOME": fixture.root.path], + lookbackDays: 7, + now: secondAt.addingTimeInterval(120), + modelsDevCatalog: Self.catalog(), + scanLimits: limits) + let snapshot = try #require(summary.toCostUsageTokenSnapshot(historyDays: 7)) + + #expect(summary.totalTokens == 22) + #expect(summary.lastSessionAt == secondAt) + #expect(!summary.historyCoverageIsEstablished) + #expect(!snapshot.historyCoverageIsEstablished) + #expect(GrokLocalSessionScanner.parseCacheTurnCountForTesting() == 1) + #expect(GrokLocalSessionScanner.parseCacheMetricsForTesting().jsonDecodeCount == 2) + } + + @Test + func `parse cache caps retained session entries globally`() throws { + GrokLocalSessionScanner.resetParseCacheForTesting() + defer { GrokLocalSessionScanner.resetParseCacheForTesting() } + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let sessionsRoot = fixture.session.deletingLastPathComponent() + let turnAt = try self.localDate(day: 20, hour: 17, minute: 50) + for index in 0..<80 { + let session = sessionsRoot.appendingPathComponent("session-\(index)", isDirectory: true) + try FileManager.default.createDirectory(at: session, withIntermediateDirectories: true) + try self.writeUpdates( + [self.turn(timestamp: turnAt, usage: self.singleModelUsage(input: 1, output: 1))], + to: session.appendingPathComponent("updates.jsonl"), + modificationDate: turnAt.addingTimeInterval(60)) + } + + let summary = try self.summarize(fixture: fixture, now: turnAt.addingTimeInterval(120)) + + #expect(summary.totalTokens == 160) + #expect(summary.historyCoverageIsEstablished) + #expect(GrokLocalSessionScanner.parseCacheEntryCountForTesting() <= 64) + #expect(GrokLocalSessionScanner.parseCacheTurnCountForTesting() <= 64) + } + + @Test + func `global scan budgets retain newest sessions and mark history incomplete`() throws { + GrokLocalSessionScanner.resetParseCacheForTesting() + defer { GrokLocalSessionScanner.resetParseCacheForTesting() } + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let sessionsRoot = fixture.session.deletingLastPathComponent() + let firstAt = try self.localDate(day: 20, hour: 18) + for index in 0..<5 { + let session = sessionsRoot.appendingPathComponent("budget-session-\(index)", isDirectory: true) + try FileManager.default.createDirectory(at: session, withIntermediateDirectories: true) + let turnAt = firstAt.addingTimeInterval(TimeInterval(index)) + try self.writeUpdates( + [self.turn(timestamp: turnAt, usage: self.singleModelUsage(input: index + 1, output: 0))], + to: session.appendingPathComponent("updates.jsonl"), + modificationDate: turnAt) + } + let limits = GrokLocalSessionScanLimits( + maximumFileBytes: 64 * 1024, + maximumLineBytes: 64 * 1024, + maximumTurnsPerFile: 10, + maximumSessions: 3, + maximumTotalBytes: 1024 * 1024, + maximumTotalTurns: 2) + + let summary = try GrokLocalSessionScanner.summarize( + env: ["GROK_HOME": fixture.root.path], + lookbackDays: 7, + now: firstAt.addingTimeInterval(60), + modelsDevCatalog: Self.catalog(), + scanLimits: limits) + + #expect(summary.totalTokens == 9) + #expect(summary.sessionCount == 2) + #expect(summary.lastSessionAt == firstAt.addingTimeInterval(4)) + #expect(!summary.historyCoverageIsEstablished) + #expect(GrokLocalSessionScanner.parseCacheEntryCountForTesting() == 2) + #expect(GrokLocalSessionScanner.parseCacheTurnCountForTesting() == 2) + } + @Test func `absurd model call count promptly falls back without iterating file content`() throws { let fixture = try self.makeFixture() diff --git a/docs/grok.md b/docs/grok.md index d3ee215df3..ac4e9f91e4 100644 --- a/docs/grok.md +++ b/docs/grok.md @@ -127,10 +127,16 @@ The grok.com billing gRPC-web endpoint remains a best-effort fallback. unknown-usage retry adopts only the validated active-period shape described above. This keeps billing visible when `grok agent stdio` returns `Method not found`. -5) **Local session signals** (informational fallback) - - Walks `~/.grok/sessions///signals.json` files (last 30 days). - - Aggregates `totalTokensBeforeCompaction`, `contextTokensUsed`, `modelsUsed`, - and the most recent session timestamp. +5) **Local completed-turn history** (informational fallback and Usage & Spend) + - Streams completed `turn_completed` records from + `~/.grok/sessions///updates.jsonl` over the requested + history window (up to 365 days). + - Aggregates the recorded per-turn token usage, model breakdown, request count, + and timestamps. Public xAI list prices provide a non-billed cost estimate. + - Reads only a bounded tail of each growing JSONL file, caps individual records + and retained parsed turns, and reports history as incomplete if a bound is hit. + - Uses `signals.json` only as a metadata fallback for sessions with no completed + turns; context-window occupancy is never counted as consumed tokens. ## OAuth credentials @@ -226,30 +232,51 @@ records credential provenance at request time. ## Local fallback (`~/.grok/sessions/`) -Each session directory contains `signals.json` with fields like: +Each session directory records completed turns in `updates.jsonl`. CodexBar reads +`params.update.sessionUpdate == "turn_completed"` records and uses the record's +timestamp and actual usage payload: ```json { - "turnCount": 1, - "contextTokensUsed": 2968, - "contextWindowTokens": 512000, - "totalTokensBeforeCompaction": 0, - "modelsUsed": ["grok-build"], - "primaryModelId": "grok-build", - "sessionDurationSeconds": 47 + "timestamp": 1787472000, + "params": { + "update": { + "sessionUpdate": "turn_completed", + "usage": { + "inputTokens": 1000, + "outputTokens": 100, + "totalTokens": 1100, + "modelCalls": 1, + "modelUsage": { + "grok-4.6-build": { + "inputTokens": 1000, + "outputTokens": 100, + "totalTokens": 1100 + } + } + } + } + } } ``` -CodexBar aggregates these into a `GrokLocalSessionSummary` (session count, total -tokens, last session time, primary model, per-day token buckets) and exposes it for -diagnostics even when the RPC path is unavailable. +CodexBar aggregates these into a `GrokLocalSessionSummary` (session count, actual +tokens, last session time, primary model, and local-day buckets) over the requested +window, up to 365 days. The reader streams a bounded tail of each file, limits a +single JSONL record to 1 MiB, and retains at most 20,000 recent turns per file. A +scan considers at most 256 recent sessions, 256 MiB, and 100,000 turns; the +process-wide LRU parse cache retains at most 64 files or 50,000 turns. If a bound +drops history, the resulting snapshot is marked incomplete instead of presenting +partial totals as complete. `signals.json` contributes model/session metadata only +when no completed turns are available and is also limited to 1 MiB. Those local daily token buckets also feed the shared Usage & Spend catalog so an enabled Grok subscription is counted instead of omitted. SuperGrok/X Premium+ credits remain a quota window on the usage bar; they are never converted into -dollars. Local session scans run on the dedicated background usage-scan queue; -menu cards and spend views reuse the already-published snapshot instead of -walking the session directory whenever they render. +dollars. Public xAI list-price dollars are shown only as a non-billed estimate. +Local session scans run on the dedicated background usage-scan queue; menu cards +and spend views reuse the already-published snapshot instead of walking the session +directory whenever they render. ## Status From 630b7cf9b5a84b776ec871700bc5cb00667dbee1 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Sun, 23 Aug 2026 03:36:37 -0700 Subject: [PATCH 09/34] Fix Grok scanner Linux build --- .../Providers/Grok/GrokLocalSessionScanner.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift index 623bc1d841..236ede3b92 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift @@ -732,7 +732,7 @@ public enum GrokLocalSessionScanner { historyCoverageIsEstablished = false return } - guard let turn = autoreleasepool(invoking: { self.decodeTurn(line.bytes) }) else { return } + guard let turn = self.decodeTurnWithScopedAutoreleasePool(line.bytes) else { return } turns.append(turn) if turns.count > compactionThreshold { turns.removeFirst(limits.maximumTurnsPerFile) @@ -756,6 +756,14 @@ public enum GrokLocalSessionScanner { cacheable: cacheable) } + private static func decodeTurnWithScopedAutoreleasePool(_ data: Data) -> GrokParsedTurn? { + #if canImport(Darwin) + autoreleasepool { self.decodeTurn(data) } + #else + self.decodeTurn(data) + #endif + } + private static func decodeTurn(_ data: Data) -> GrokParsedTurn? { guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let timestamp = self.integer(json["timestamp"]), From ef2fcfca1cef084160991710703fe22664693814 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Sun, 23 Aug 2026 11:39:25 -0700 Subject: [PATCH 10/34] Document Grok usage pricing --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 228e687fe9..1700152cf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -211,6 +211,9 @@ - Spend: add tokscale-compatible local readers for Cursor and Antigravity local history (#3113). Thanks @Yuxin-Qiao! - Added CHF (Swiss Franc) to the display currency options (#3149). +### Usage & Spend +- Grok: count completed-turn usage from bounded local CLI session-log scans instead of context-window occupancy, and show the result as a clearly labeled, non-billed public xAI list-price estimate; OpenCodex xAI history remains token-only without request-time credential provenance (#3135). Thanks @olddonkey! + ## 0.54.1 — 2026-08-21 ### Highlights From a70e803dd3b00092b1500468fff7db6ec5a6bc86 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Sun, 23 Aug 2026 13:07:54 -0700 Subject: [PATCH 11/34] Deduplicate compatible parser hash --- Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift | 2 +- Tests/CodexBarTests/CostUsageStoreTests.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift index 1b3790ff95..9c58d94867 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift @@ -99,12 +99,12 @@ actor CostUsageStore { "dd19ffa2dcfa8d47", // Current main before report-window scoping; persisted rows unchanged. "8050a4faf4fddb96", // PR base before retained-report persistence; parsed rows unchanged. "cfd84d13ad7d4cfa", // 0.55.x scan scheduling and progress bookkeeping; persisted rows unchanged. + "3c984b655688593f", // xAI pricing and row-ownership evidence only; persisted Codex rows unchanged. "98da5914d2f6a9cd", // Pushed PR producer before retry signaling; persisted rows unchanged. "43609cc56f76a003", // 0.49.3 request-tier pricing; persisted row shape unchanged. "b975eb705f905b9a", // 0.49.0-0.49.2 SQLite producer with compatible rows. "47144baa8daccf52", // This branch changes only scan scheduling, discovery, and persistence bookkeeping. "2d17f4981b78d07f", // Persisted priority-turn cursor; parser and persisted row shape unchanged. - "3c984b655688593f", // 0.54.x row-ownership evidence fix; parser and persisted row shape unchanged. "5f8507161b23757c", // 0.54.2 tokscale parity + priority evidence; persisted row shape unchanged. ] static let incompatibleRetainedReportPredecessorParserHashes: Set = [ diff --git a/Tests/CodexBarTests/CostUsageStoreTests.swift b/Tests/CodexBarTests/CostUsageStoreTests.swift index 5ae9ef913a..2d60ae95c8 100644 --- a/Tests/CodexBarTests/CostUsageStoreTests.swift +++ b/Tests/CodexBarTests/CostUsageStoreTests.swift @@ -1047,12 +1047,12 @@ extension CostUsageStoreTests { "dd19ffa2dcfa8d47", "8050a4faf4fddb96", "cfd84d13ad7d4cfa", + "3c984b655688593f", "98da5914d2f6a9cd", "43609cc56f76a003", "b975eb705f905b9a", "47144baa8daccf52", "2d17f4981b78d07f", - "3c984b655688593f", "5f8507161b23757c", ]) let predecessorVersion = CostUsageStore.combinedSchemaVersion( From 8ba132be82f6ba22e55043cebcd6b0e44ff3b3e0 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Sun, 23 Aug 2026 22:44:35 -0700 Subject: [PATCH 12/34] Fix rebased Grok test integration --- .../OpenCodexUsageFanOutTests.swift | 170 +++++++++--------- 1 file changed, 85 insertions(+), 85 deletions(-) diff --git a/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift b/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift index 23e6da98a8..975570b63f 100644 --- a/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift +++ b/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift @@ -589,6 +589,91 @@ struct OpenCodexUsageFanOutTests { ]), ]) } + + private static func pricingSnapshot( + provider: String, + model: String, + catalog: ModelsDevCatalog) throws -> CostUsageTokenSnapshot + { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let now = Date(timeIntervalSince1970: 1_787_270_400) + return OpenCodexUsageAggregator.snapshot( + entries: [ + OpenCodexUsageEntry( + requestID: "pricing-\(provider)", + timestamp: now, + provider: provider, + model: model, + usageStatus: .reported, + usage: OpenCodexTokenUsage( + inputTokens: 1000, + outputTokens: 100, + cacheReadInputTokens: 200, + totalTokens: 1100), + totalTokens: 1100), + ], + now: now, + historyDays: 7, + calendar: calendar, + modelsDevCatalog: catalog) + } + + private static func xaiEntries(now: Date) -> [OpenCodexUsageEntry] { + [ + OpenCodexUsageEntry( + requestID: "xai-1", + timestamp: now, + provider: "xai", + model: "grok-4.6", + usageStatus: .reported, + usage: OpenCodexTokenUsage(inputTokens: 100, outputTokens: 20, totalTokens: 120), + totalTokens: 120), + OpenCodexUsageEntry( + requestID: "xai-2", + timestamp: now, + provider: "xai", + model: "xai/grok-4.6", + usageStatus: .reported, + usage: OpenCodexTokenUsage(inputTokens: 60, outputTokens: 20, totalTokens: 80), + totalTokens: 80), + ] + } + + private static func pricingCatalog() throws -> ModelsDevCatalog { + let json = """ + { + "xai": { + "id": "xai", + "models": { + "grok-4.6": { + "id": "grok-4.6", + "cost": { "input": 2, "output": 6, "cache_read": 0.5 } + } + } + }, + "openai": { + "id": "openai", + "models": { + "gpt-5.6-sol": { + "id": "gpt-5.6-sol", + "cost": { "input": 1, "output": 4, "cache_read": 0.25 } + } + } + }, + "kimi": { + "id": "kimi", + "models": { + "k3[1m]": { + "id": "k3[1m]", + "cost": { "input": 9, "output": 19 } + } + } + } + } + """ + return try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8)) + } } private enum OpenCodexUsageSnapshotReference { @@ -898,89 +983,4 @@ private enum OpenCodexUsageSnapshotReference { case (nil, nil): nil } } - - private static func pricingSnapshot( - provider: String, - model: String, - catalog: ModelsDevCatalog) throws -> CostUsageTokenSnapshot - { - var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) - let now = Date(timeIntervalSince1970: 1_787_270_400) - return OpenCodexUsageAggregator.snapshot( - entries: [ - OpenCodexUsageEntry( - requestID: "pricing-\(provider)", - timestamp: now, - provider: provider, - model: model, - usageStatus: .reported, - usage: OpenCodexTokenUsage( - inputTokens: 1000, - outputTokens: 100, - cacheReadInputTokens: 200, - totalTokens: 1100), - totalTokens: 1100), - ], - now: now, - historyDays: 7, - calendar: calendar, - modelsDevCatalog: catalog) - } - - private static func xaiEntries(now: Date) -> [OpenCodexUsageEntry] { - [ - OpenCodexUsageEntry( - requestID: "xai-1", - timestamp: now, - provider: "xai", - model: "grok-4.6", - usageStatus: .reported, - usage: OpenCodexTokenUsage(inputTokens: 100, outputTokens: 20, totalTokens: 120), - totalTokens: 120), - OpenCodexUsageEntry( - requestID: "xai-2", - timestamp: now, - provider: "xai", - model: "xai/grok-4.6", - usageStatus: .reported, - usage: OpenCodexTokenUsage(inputTokens: 60, outputTokens: 20, totalTokens: 80), - totalTokens: 80), - ] - } - - private static func pricingCatalog() throws -> ModelsDevCatalog { - let json = """ - { - "xai": { - "id": "xai", - "models": { - "grok-4.6": { - "id": "grok-4.6", - "cost": { "input": 2, "output": 6, "cache_read": 0.5 } - } - } - }, - "openai": { - "id": "openai", - "models": { - "gpt-5.6-sol": { - "id": "gpt-5.6-sol", - "cost": { "input": 1, "output": 4, "cache_read": 0.25 } - } - } - }, - "kimi": { - "id": "kimi", - "models": { - "k3[1m]": { - "id": "k3[1m]", - "cost": { "input": 9, "output": 19 } - } - } - } - } - """ - return try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8)) - } } From f7cdead2d57daa471b3bed8714c2dfbec1368e9f Mon Sep 17 00:00:00 2001 From: olddonkey Date: Mon, 24 Aug 2026 02:09:31 -0700 Subject: [PATCH 13/34] Move Grok changelog to 0.55.1 --- CHANGELOG.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1700152cf7..228e687fe9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -211,9 +211,6 @@ - Spend: add tokscale-compatible local readers for Cursor and Antigravity local history (#3113). Thanks @Yuxin-Qiao! - Added CHF (Swiss Franc) to the display currency options (#3149). -### Usage & Spend -- Grok: count completed-turn usage from bounded local CLI session-log scans instead of context-window occupancy, and show the result as a clearly labeled, non-billed public xAI list-price estimate; OpenCodex xAI history remains token-only without request-time credential provenance (#3135). Thanks @olddonkey! - ## 0.54.1 — 2026-08-21 ### Highlights From 2924c462ac9100350cd46ae7d62f1bd53d48c034 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Mon, 24 Aug 2026 03:19:42 -0700 Subject: [PATCH 14/34] Restore Grok menu fallback --- .../CodexBar/StatusItemController+Menu.swift | 2 +- .../StatusItemController+MenuCardModel.swift | 6 +- Sources/CodexBar/UsageStore+TokenCost.swift | 25 +++++ .../GrokStatusMenuFallbackTests.swift | 104 ++++++++++++++++++ 4 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 Tests/CodexBarTests/GrokStatusMenuFallbackTests.swift diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 0d6830a63a..d36d36beaa 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1545,7 +1545,7 @@ extension StatusItemController { } func tokenSnapshotForCostHistorySubmenu(provider: UsageProvider) -> CostUsageTokenSnapshot? { - let projected = self.store.tokenSnapshot( + let projected = self.store.tokenSnapshotForLiveProviderConsumer( fromProviderSnapshot: self.store.snapshot(for: provider.instanceID), provider: provider) if UsageStore.tokenCostRequiresProviderSnapshot(provider) { diff --git a/Sources/CodexBar/StatusItemController+MenuCardModel.swift b/Sources/CodexBar/StatusItemController+MenuCardModel.swift index bf98a07fa8..2bcce0d0f0 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardModel.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardModel.swift @@ -40,7 +40,11 @@ extension StatusItemController { provider: target, surface: surface, override: snapshotOverride) - let projectedTokenSnapshot = self.store.tokenSnapshot(fromProviderSnapshot: snapshot, provider: target) + let projectedTokenSnapshot = if surface == .liveCard { + self.store.tokenSnapshotForLiveProviderConsumer(fromProviderSnapshot: snapshot, provider: target) + } else { + self.store.tokenSnapshot(fromProviderSnapshot: snapshot, provider: target) + } let storedTokenSnapshot = UsageStore.tokenCostRequiresProviderSnapshot(target) ? nil : self.store.tokenSnapshot(for: target) diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 43588647bb..6d41e741ea 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -639,6 +639,31 @@ extension UsageStore { return nil } + func tokenSnapshotForLiveProviderConsumer( + fromProviderSnapshot snapshot: UsageSnapshot?, + provider: UsageProvider, + historyDays: Int? = nil) + -> CostUsageTokenSnapshot? + { + if let projected = self.tokenSnapshot( + fromProviderSnapshot: snapshot, + provider: provider, + historyDays: historyDays) + { + return projected + } + // Provider-specific by design: Grok's remote probe may fail while its local session scan still + // publishes a valid estimate. Keep this fallback scoped to live consumers so account override + // cards cannot inherit provider-level data from a different context. + guard provider == .grok, + let published = self.tokenSnapshotPublicationForCurrentProviderConfig(for: provider)?.snapshot + else { return nil } + let windowDays = historyDays ?? self.settings.costUsageHistoryDays + return published.narrowed( + toHistoryDays: windowDays, + calendar: self.settings.costUsageBucketCalendar) + } + nonisolated static func tokenCostNoDataMessage(for provider: UsageProvider) -> String { ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.noDataMessage() } diff --git a/Tests/CodexBarTests/GrokStatusMenuFallbackTests.swift b/Tests/CodexBarTests/GrokStatusMenuFallbackTests.swift new file mode 100644 index 0000000000..b628b7876f --- /dev/null +++ b/Tests/CodexBarTests/GrokStatusMenuFallbackTests.swift @@ -0,0 +1,104 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension StatusMenuTests { + @Test + func `grok live menu consumers use published local fallback without remote snapshot`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.selectedMenuProvider = .grok + settings.costUsageEnabled = true + settings.costSummaryDisplayStyle = .both + + let metadata = try #require(ProviderRegistry.shared.metadata[.grok]) + settings.setProviderEnabled(provider: .grok, metadata: metadata, enabled: true) + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let now = Date() + let formatter = DateFormatter() + formatter.calendar = settings.costUsageBucketCalendar + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = settings.costUsageBucketCalendar.timeZone + formatter.dateFormat = "yyyy-MM-dd" + store._setTokenSnapshotForTesting(CostUsageTokenSnapshot( + sessionTokens: 77, + sessionCostUSD: 0.07, + last30DaysTokens: 77, + last30DaysCostUSD: 0.07, + daily: [ + CostUsageDailyReport.Entry( + date: formatter.string(from: now), + inputTokens: nil, + outputTokens: nil, + totalTokens: 77, + costUSD: 0.07, + modelsUsed: ["grok-4"], + modelBreakdowns: nil), + ], + updatedAt: now), provider: .grok) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let model = try #require(controller.menuCardModel(for: .grok)) + #expect(model.tokenUsage?.monthLine.contains("77") == true) + let historySnapshot = try #require(controller.tokenSnapshotForCostHistorySubmenu(provider: .grok)) + #expect(historySnapshot.last30DaysTokens == 77) + #expect(controller.makeCostHistorySubmenu(provider: .grok) != nil) + } + + @Test + func `grok override card does not inherit published local fallback`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.costUsageEnabled = true + + let metadata = try #require(ProviderRegistry.shared.metadata[.grok]) + settings.setProviderEnabled(provider: .grok, metadata: metadata, enabled: true) + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let now = Date(timeIntervalSince1970: 1_777_344_000) + store._setTokenSnapshotForTesting(CostUsageTokenSnapshot( + sessionTokens: 77, + sessionCostUSD: 0.07, + last30DaysTokens: 77, + last30DaysCostUSD: 0.07, + daily: [], + updatedAt: now), provider: .grok) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let model = try #require(controller.menuCardModel( + for: .grok, + snapshotOverride: UsageSnapshot(primary: nil, secondary: nil, updatedAt: now), + forceOverrideCard: true)) + #expect(model.tokenUsage == nil) + } +} From eb9b15e90eb8f2e0330b5b921d76219dff2d535d Mon Sep 17 00:00:00 2001 From: olddonkey Date: Mon, 24 Aug 2026 04:12:49 -0700 Subject: [PATCH 15/34] Rescan empty Grok fallback --- Sources/CodexBar/UsageStore+TokenCost.swift | 4 +-- .../GrokLocalSessionScannerTests.swift | 33 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 6d41e741ea..8b19016011 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -530,8 +530,8 @@ extension UsageStore { // Provider-specific by design: this fallback owns Grok's local session scan and publication. let provider = UsageProvider.grok let requestedHistoryDays = min(max(1, historyDays), GrokLocalSessionScanner.maximumLookbackDays) - if let publication = self.tokenSnapshotPublicationForCurrentProviderConfig(for: provider) { - return publication.snapshot?.narrowed( + if let published = self.tokenSnapshotPublicationForCurrentProviderConfig(for: provider)?.snapshot { + return published.narrowed( toHistoryDays: requestedHistoryDays, calendar: self.settings.costUsageBucketCalendar) } diff --git a/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift b/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift index b46fb8e850..7e10afbd24 100644 --- a/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift +++ b/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift @@ -588,6 +588,39 @@ struct GrokLocalSessionScannerTests: GrokLocalSessionScannerTestSupport { #expect(empty == nil) #expect(store.tokenSnapshotPublicationForCurrentProviderConfig(for: .grok)?.snapshot == nil) + + let newTurnAt = turnAt.addingTimeInterval(180) + try self.writeUpdates( + [self.turn(timestamp: newTurnAt, usage: self.singleModelUsage(input: 70, output: 7))], + to: updates, + modificationDate: newTurnAt.addingTimeInterval(60)) + let catalog = try Self.catalog() + fallbackScanCount = 0 + store._test_grokLocalTokenScannerOverride = { historyDays in + fallbackScanCount += 1 + return GrokLocalSessionScanner.summarize( + env: ["GROK_HOME": fixture.root.path], + lookbackDays: historyDays, + now: newTurnAt.addingTimeInterval(120), + modelsDevCatalog: catalog) + .toCostUsageTokenSnapshot(historyDays: historyDays) + } + + await store.refreshProvider(.grok) + for _ in 0..<100 { + if store.tokenSnapshotPublicationForCurrentProviderConfig(for: .grok)?.snapshot?.last30DaysTokens == 77 { + break + } + await Task.yield() + } + + #expect(fallbackScanCount == 1) + #expect(store.tokenSnapshotPublicationForCurrentProviderConfig(for: .grok)?.snapshot?.last30DaysTokens == 77) + + await store.refreshProvider(.grok) + await Task.yield() + + #expect(fallbackScanCount == 1) } @Test From 4e9f3b421cd1db3a649f884c56b03ef5fc4a5654 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Mon, 24 Aug 2026 21:07:18 -0700 Subject: [PATCH 16/34] Address Grok review findings --- .../PreferencesSpendDashboardPane.swift | 10 ++- Sources/CodexBar/SpendDashboardModel.swift | 6 ++ Sources/CodexBar/UsageStore+TokenCost.swift | 5 -- .../Grok/GrokLocalSessionScanner.swift | 22 ++++- .../Grok/GrokProviderDescriptor.swift | 6 +- .../Vendored/CostUsage/CostUsagePricing.swift | 14 +-- .../Vendored/CostUsage/CostUsageStore.swift | 1 + .../OpenCodexUsageAggregator.swift | 6 ++ .../CostHistoryChartMenuViewTests.swift | 3 + .../CodexBarTests/CostUsagePricingTests.swift | 62 +++++++++++++ Tests/CodexBarTests/CostUsageStoreTests.swift | 1 + .../GrokLocalSessionScannerTests.swift | 88 ++++++++++++++++--- .../GrokXAISpendCatalogTests.swift | 48 +++++++++- .../OpenCodexUsageFanOutTests.swift | 8 +- .../ProviderArchitectureGatekeeperTests.swift | 2 +- docs/grok.md | 5 +- 16 files changed, 252 insertions(+), 35 deletions(-) diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index b29ec8650f..d137861091 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -674,7 +674,15 @@ private struct SpendProviderPanel: View { .foregroundStyle(.tertiary) .frame(width: 26, alignment: .leading) SpendProviderIcon(provider: row.provider, sourceKind: row.sourceKind) - Text(row.displayName).lineLimit(1) + VStack(alignment: .leading, spacing: 2) { + Text(row.displayName).lineLimit(1) + if let disclaimer = row.costDisclaimer { + Text(disclaimer) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } Spacer() Text( row.totalCost == nil && row.totalTokens == nil diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index f62c9c450b..f8256037a1 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -53,6 +53,12 @@ struct SpendDashboardModel: Equatable, Sendable { let coveredDayCount: Int let sourceKind: SourceKind + var costDisclaimer: String? { + // Provider-specific by design: Grok local-session dollars are list-price estimates, not billed spend. + guard self.provider == .grok, self.totalCost != nil else { return nil } + return UsageFormatter.costEstimateHint(provider: self.provider) + } + init( id: String, rank: Int, diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 8b19016011..07c2a260d2 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -530,11 +530,6 @@ extension UsageStore { // Provider-specific by design: this fallback owns Grok's local session scan and publication. let provider = UsageProvider.grok let requestedHistoryDays = min(max(1, historyDays), GrokLocalSessionScanner.maximumLookbackDays) - if let published = self.tokenSnapshotPublicationForCurrentProviderConfig(for: provider)?.snapshot { - return published.narrowed( - toHistoryDays: requestedHistoryDays, - calendar: self.settings.costUsageBucketCalendar) - } if let task = self.grokLocalTokenScanTask { return await task.value?.narrowed( toHistoryDays: requestedHistoryDays, diff --git a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift index 236ede3b92..38a1544a37 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift @@ -131,6 +131,7 @@ struct GrokLocalSessionScanLimits: Sendable, Equatable { maximumLineBytes: 1024 * 1024, maximumTurnsPerFile: 20000, maximumSessions: 256, + maximumDiscoveryEntries: 4096, maximumTotalBytes: 256 * 1024 * 1024, maximumTotalTurns: 100_000) @@ -138,6 +139,7 @@ struct GrokLocalSessionScanLimits: Sendable, Equatable { let maximumLineBytes: Int let maximumTurnsPerFile: Int let maximumSessions: Int + let maximumDiscoveryEntries: Int let maximumTotalBytes: Int64 let maximumTotalTurns: Int @@ -146,6 +148,7 @@ struct GrokLocalSessionScanLimits: Sendable, Equatable { maximumLineBytes: Int, maximumTurnsPerFile: Int, maximumSessions: Int = 256, + maximumDiscoveryEntries: Int = 4096, maximumTotalBytes: Int64 = 256 * 1024 * 1024, maximumTotalTurns: Int = 100_000) { @@ -153,6 +156,7 @@ struct GrokLocalSessionScanLimits: Sendable, Equatable { self.maximumLineBytes = max(1, maximumLineBytes) self.maximumTurnsPerFile = max(1, maximumTurnsPerFile) self.maximumSessions = max(1, maximumSessions) + self.maximumDiscoveryEntries = max(1, maximumDiscoveryEntries) self.maximumTotalBytes = max(1, maximumTotalBytes) self.maximumTotalTurns = max(1, maximumTotalTurns) } @@ -163,6 +167,7 @@ struct GrokLocalSessionScanLimits: Sendable, Equatable { maximumLineBytes: self.maximumLineBytes, maximumTurnsPerFile: self.maximumTurnsPerFile, maximumSessions: self.maximumSessions, + maximumDiscoveryEntries: self.maximumDiscoveryEntries, maximumTotalBytes: self.maximumTotalBytes, maximumTotalTurns: self.maximumTotalTurns) } @@ -496,7 +501,8 @@ public enum GrokLocalSessionScanner { root: root, fileManager: fileManager, lookbackCutoff: lookbackCutoff, - maximumCount: scanLimits.maximumSessions) + maximumCount: scanLimits.maximumSessions, + maximumDiscoveryEntries: scanLimits.maximumDiscoveryEntries) else { return self.emptySummary(now: now) } @@ -649,7 +655,8 @@ public enum GrokLocalSessionScanner { root: URL, fileManager: FileManager, lookbackCutoff: Date, - maximumCount: Int) -> RecentSessionSelection? + maximumCount: Int, + maximumDiscoveryEntries: Int) -> RecentSessionSelection? { guard let rootEnum = fileManager.enumerator( at: root, @@ -660,7 +667,11 @@ public enum GrokLocalSessionScanner { var sessionModificationDates: [String: Date] = [:] var historyCoverageIsEstablished = true let trimThreshold = maximumCount > Int.max / 2 ? Int.max : maximumCount * 2 - while let url = rootEnum.nextObject() as? URL { + var discoveryEntryCount = 0 + while discoveryEntryCount < maximumDiscoveryEntries, + let url = rootEnum.nextObject() as? URL + { + discoveryEntryCount += 1 guard !Task.isCancelled else { return nil } let name = url.lastPathComponent guard name == "updates.jsonl" || name == "signals.json" else { continue } @@ -676,6 +687,11 @@ public enum GrokLocalSessionScanner { self.trimRecentSessions(&sessionModificationDates, maximumCount: maximumCount) } } + if discoveryEntryCount == maximumDiscoveryEntries { + // DirectoryEnumerator is lazy, so stopping here bounds both traversal and metadata I/O. Conservatively + // mark coverage incomplete at the boundary because there may be undiscovered sessions after this point. + historyCoverageIsEstablished = false + } if sessionModificationDates.count > maximumCount { historyCoverageIsEstablished = false self.trimRecentSessions(&sessionModificationDates, maximumCount: maximumCount) diff --git a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift index 8d0aae66b8..af3b82181c 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift @@ -89,7 +89,11 @@ public enum GrokProviderDescriptor { noDataMessage: { "Grok totals come from local Grok CLI session logs. " + "Costs are public list-price estimates, not a bill." - }), + }, + menuHintLines: [.estimate], + showsHintInProviderDetails: true, + estimateDisclaimer: "Public xAI list-price estimate · not a bill.", + chartEstimateDisclaimer: .estimate), pace: ProviderPaceCapability( resetWindowPace: .custom { window, now in guard Self.primaryLabel(window: window, now: now) == "Weekly", diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 429ecfaee4..403843bef3 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -464,11 +464,7 @@ enum CostUsagePricing { ] static let codexModelsDevProviderID = "openai" - /// Provider IDs emitted by Codex-compatible clients that have matching entries in models.dev. - /// - /// The route prefix is part of the model identity for local usage estimates. Keep both the - /// client-facing aliases and their models.dev provider IDs here so pricing-cache fingerprints - /// invalidate when any supported route's rates change. + /// Provider IDs whose rates contribute to Codex pricing-cache fingerprints. static let codexModelsDevProviderIDs: Set = [ "deepseek", "kimi-coding", @@ -477,8 +473,12 @@ enum CostUsagePricing { "opencode", "opencode-free", "opencode-go", - "xai", ] + /// xAI rates price native Grok session summaries, not Codex subscription history. Keep their fingerprint scope + /// separate so an xAI catalog update cannot invalidate the unrelated Codex session cache. + static let xaiModelsDevProviderIDs: Set = ["xai"] + private static let codexCompatibleModelsDevProviderIDs = CostUsagePricing.codexModelsDevProviderIDs + .union(CostUsagePricing.xaiModelsDevProviderIDs) private static let claudeModelsDevProviderID = "anthropic" /// Returns the provider/model identities that may price a Codex model. Keep this mapping @@ -491,7 +491,7 @@ enum CostUsagePricing { let routeID = String(trimmed[.. Double? { + // Provider-specific by design: token-only routes lack the request-time credential provenance needed to + // decide whether their traffic belongs to a subscription or an API bill. Keep their standalone OpenCodex + // rows token-only too instead of attaching a dollar amount that the subscription fan-out intentionally drops. + guard OpenCodexRouteDispatcher.route(provider: entry.provider, modelName: entry.model) != .tokenOnly else { + return nil + } guard entry.usageStatus == .reported || entry.usageStatus == .estimated else { return nil } let usage = entry.usage let hasTokenData = entry.resolvedTotalTokens != nil diff --git a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift index 11d20dde89..d4b6954894 100644 --- a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift +++ b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift @@ -225,6 +225,9 @@ struct CostHistoryChartMenuViewTests { #expect( CostHistoryChartMenuView.estimateDisclaimer(provider: .codex) == "Estimated from token usage · not a subscription bill") + #expect( + CostHistoryChartMenuView.estimateDisclaimer(provider: .grok) + == "Public xAI list-price estimate · not a bill.") #expect(CostHistoryChartMenuView.estimateDisclaimer(provider: .claude) == nil) } diff --git a/Tests/CodexBarTests/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift index 58b4d13663..6f24c055b1 100644 --- a/Tests/CodexBarTests/CostUsagePricingTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingTests.swift @@ -348,6 +348,68 @@ struct CostUsagePricingTests { #expect(withoutKey != withEmptyKey) } + @Test + func `xai catalog changes use a separate fingerprint from Codex caches`() throws { + let first = try Self.modelsDevArtifact(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-5.6-sol": { + "id": "gpt-5.6-sol", + "cost": { "input": 5, "output": 30 } + } + } + }, + "xai": { + "id": "xai", + "models": { + "grok-4.6": { + "id": "grok-4.6", + "cost": { "input": 2, "output": 6 } + } + } + } + } + """) + let xaiPriceChanged = try Self.modelsDevArtifact(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-5.6-sol": { + "id": "gpt-5.6-sol", + "cost": { "input": 5, "output": 30 } + } + } + }, + "xai": { + "id": "xai", + "models": { + "grok-4.6": { + "id": "grok-4.6", + "cost": { "input": 3, "output": 7 } + } + } + } + } + """) + + let firstCodexKey = CostUsagePricingKey.codex(modelsDevArtifact: first, formulaVersion: 1) + let changedCodexKey = CostUsagePricingKey.codex(modelsDevArtifact: xaiPriceChanged, formulaVersion: 1) + let firstXAIKey = CostUsagePricingKey.codex( + modelsDevArtifact: first, + formulaVersion: 1, + modelsDevProviderIDs: CostUsagePricing.xaiModelsDevProviderIDs) + let changedXAIKey = CostUsagePricingKey.codex( + modelsDevArtifact: xaiPriceChanged, + formulaVersion: 1, + modelsDevProviderIDs: CostUsagePricing.xaiModelsDevProviderIDs) + + #expect(firstCodexKey == changedCodexKey) + #expect(firstXAIKey != changedXAIKey) + } + @Test func `codex pricing fingerprint records API fast USD definition`() { let fingerprint = CostUsagePricing.codexBuiltInPricingFingerprint() diff --git a/Tests/CodexBarTests/CostUsageStoreTests.swift b/Tests/CodexBarTests/CostUsageStoreTests.swift index 2d60ae95c8..8c3dc25780 100644 --- a/Tests/CodexBarTests/CostUsageStoreTests.swift +++ b/Tests/CodexBarTests/CostUsageStoreTests.swift @@ -1046,6 +1046,7 @@ extension CostUsageStoreTests { "c6c46a376ba16304", "dd19ffa2dcfa8d47", "8050a4faf4fddb96", + "64c5844b8ea170a6", "cfd84d13ad7d4cfa", "3c984b655688593f", "98da5914d2f6a9cd", diff --git a/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift b/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift index 7e10afbd24..92abce7e57 100644 --- a/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift +++ b/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift @@ -304,6 +304,41 @@ struct GrokLocalSessionScannerTests: GrokLocalSessionScannerTestSupport { #expect(GrokLocalSessionScanner.parseCacheMetricsForTesting().jsonDecodeCount == 2) } + @Test + func `session discovery stops at its entry budget and marks history incomplete`() throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let sessionsRoot = fixture.session.deletingLastPathComponent() + let turnAt = try self.localDate(day: 20, hour: 17, minute: 45) + try self.writeUpdates( + [self.turn(timestamp: turnAt, usage: self.singleModelUsage(input: 10, output: 1))], + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: turnAt) + let secondSession = sessionsRoot.appendingPathComponent("session-b", isDirectory: true) + try FileManager.default.createDirectory(at: secondSession, withIntermediateDirectories: true) + try self.writeUpdates( + [self.turn(timestamp: turnAt, usage: self.singleModelUsage(input: 20, output: 2))], + to: secondSession.appendingPathComponent("updates.jsonl"), + modificationDate: turnAt.addingTimeInterval(1)) + let limits = GrokLocalSessionScanLimits( + maximumFileBytes: 64 * 1024, + maximumLineBytes: 64 * 1024, + maximumTurnsPerFile: 10, + maximumSessions: 10, + maximumDiscoveryEntries: 3) + + let summary = try GrokLocalSessionScanner.summarize( + env: ["GROK_HOME": fixture.root.path], + lookbackDays: 7, + now: turnAt.addingTimeInterval(120), + modelsDevCatalog: Self.catalog(), + scanLimits: limits) + + #expect(summary.sessionCount == 1) + #expect(summary.totalTokens == 11 || summary.totalTokens == 22) + #expect(!summary.historyCoverageIsEstablished) + } + @Test func `parse cache caps retained session entries globally`() throws { GrokLocalSessionScanner.resetParseCacheForTesting() @@ -543,8 +578,9 @@ struct GrokLocalSessionScannerTests: GrokLocalSessionScannerTestSupport { defer { try? FileManager.default.removeItem(at: fixture.root) } let turnAt = try self.localDate(day: 20, hour: 19, minute: 30) let updates = fixture.session.appendingPathComponent("updates.jsonl") + let firstTurn = self.turn(timestamp: turnAt, usage: self.singleModelUsage(input: 70, output: 7)) try self.writeUpdates( - [self.turn(timestamp: turnAt, usage: self.singleModelUsage(input: 70, output: 7))], + [firstTurn], to: updates, modificationDate: turnAt.addingTimeInterval(60)) let settings = testSettingsStore(suiteName: "GrokLocalSessionScannerTests-detached") @@ -563,27 +599,57 @@ struct GrokLocalSessionScannerTests: GrokLocalSessionScannerTestSupport { #expect(store.tokenSnapshotPublicationForCurrentProviderConfig(for: .grok)?.snapshot?.last30DaysTokens == 77) var fallbackScanCount = 0 - store._test_grokLocalTokenScannerOverride = { _ in + let catalog = try Self.catalog() + store._test_grokLocalTokenScannerOverride = { historyDays in fallbackScanCount += 1 - return nil + return GrokLocalSessionScanner.summarize( + env: ["GROK_HOME": fixture.root.path], + lookbackDays: historyDays, + now: turnAt.addingTimeInterval(600), + modelsDevCatalog: catalog) + .toCostUsageTokenSnapshot(historyDays: historyDays) } store._test_providerFetchOutcomeOverride = { provider in #expect(provider == .grok) return ProviderFetchOutcome(result: .failure(URLError(.badServerResponse)), attempts: []) } await store.refreshProvider(.grok) + for _ in 0..<100 { + if fallbackScanCount >= 1 { break } + await Task.yield() + } - #expect(fallbackScanCount == 0) + #expect(fallbackScanCount == 1) #expect(store.tokenSnapshotPublicationForCurrentProviderConfig(for: .grok)?.snapshot?.last30DaysTokens == 77) + let secondTurnAt = turnAt.addingTimeInterval(180) + let secondTurn = self.turn( + timestamp: secondTurnAt, + usage: self.singleModelUsage(input: 20, output: 3)) + try self.writeUpdates( + [firstTurn, secondTurn], + to: updates, + modificationDate: secondTurnAt.addingTimeInterval(60)) await store.refreshProvider(.grok) + for _ in 0..<100 { + if fallbackScanCount >= 2, + store.tokenSnapshotPublicationForCurrentProviderConfig(for: .grok)?.snapshot?.last30DaysTokens == 100 + { + break + } + await Task.yield() + } - #expect(fallbackScanCount == 0) - #expect(store.tokenSnapshotPublicationForCurrentProviderConfig(for: .grok)?.snapshot?.last30DaysTokens == 77) + #expect(fallbackScanCount == 2) + #expect(store.tokenSnapshotPublicationForCurrentProviderConfig(for: .grok)?.snapshot?.last30DaysTokens == 100) + for _ in 0..<100 { + if store.grokLocalTokenScanTask == nil { break } + await Task.yield() + } + #expect(store.grokLocalTokenScanTask == nil) store._test_grokLocalTokenScannerOverride = nil try FileManager.default.removeItem(at: updates) - store.clearTokenSnapshot(for: .grok) let empty = await store.scanAndPublishGrokLocalTokenSnapshot(historyDays: 7) #expect(empty == nil) @@ -594,7 +660,6 @@ struct GrokLocalSessionScannerTests: GrokLocalSessionScannerTestSupport { [self.turn(timestamp: newTurnAt, usage: self.singleModelUsage(input: 70, output: 7))], to: updates, modificationDate: newTurnAt.addingTimeInterval(60)) - let catalog = try Self.catalog() fallbackScanCount = 0 store._test_grokLocalTokenScannerOverride = { historyDays in fallbackScanCount += 1 @@ -618,9 +683,12 @@ struct GrokLocalSessionScannerTests: GrokLocalSessionScannerTestSupport { #expect(store.tokenSnapshotPublicationForCurrentProviderConfig(for: .grok)?.snapshot?.last30DaysTokens == 77) await store.refreshProvider(.grok) - await Task.yield() + for _ in 0..<100 { + if fallbackScanCount >= 2 { break } + await Task.yield() + } - #expect(fallbackScanCount == 1) + #expect(fallbackScanCount == 2) } @Test diff --git a/Tests/CodexBarTests/GrokXAISpendCatalogTests.swift b/Tests/CodexBarTests/GrokXAISpendCatalogTests.swift index df94354c5f..332b07f2a6 100644 --- a/Tests/CodexBarTests/GrokXAISpendCatalogTests.swift +++ b/Tests/CodexBarTests/GrokXAISpendCatalogTests.swift @@ -8,10 +8,54 @@ struct GrokXAISpendCatalogTests { func `grok and xai publish through the snapshot-backed spend catalog`() { #expect(UsageStore.tokenCostRequiresProviderSnapshot(.grok)) #expect(UsageStore.tokenCostRequiresProviderSnapshot(.xai)) - #expect(ProviderDescriptorRegistry.descriptor(for: .grok).tokenCost.supportsTokenCost) + let grokTokenCost = ProviderDescriptorRegistry.descriptor(for: .grok).tokenCost + #expect(grokTokenCost.supportsTokenCost) #expect(ProviderDescriptorRegistry.descriptor(for: .xai).tokenCost.supportsTokenCost) - #expect(ProviderDescriptorRegistry.descriptor(for: .grok).tokenCost.noDataMessage() == + #expect(grokTokenCost.noDataMessage() == "Grok totals come from local Grok CLI session logs. Costs are public list-price estimates, not a bill.") + #expect(grokTokenCost.menuHintLines == [.estimate]) + #expect(grokTokenCost.showsHintInProviderDetails) + #expect(grokTokenCost.estimateDisclaimer == "Public xAI list-price estimate · not a bill.") + #expect(grokTokenCost.chartEstimateDisclaimer == .estimate) + } + + @MainActor + @Test + func `populated Grok surfaces disclose that list price is not a bill`() throws { + let now = Date(timeIntervalSince1970: 1_787_587_200) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 1100, + sessionCostUSD: 0.0023, + last30DaysTokens: 1100, + last30DaysCostUSD: 0.0023, + historyDays: 7, + costProvenance: .listPriceEstimate, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-08-24", + inputTokens: 1000, + outputTokens: 100, + totalTokens: 1100, + costUSD: 0.0023, + modelsUsed: ["grok-4.6"], + modelBreakdowns: nil), + ], + updatedAt: now) + let section = try #require(UsageMenuCardView.Model.tokenUsageSection( + provider: .grok, + enabled: true, + comparisonPeriodsEnabled: false, + snapshot: snapshot, + error: nil)) + let dashboard = SpendDashboardModel.build( + inputs: [.init(provider: .grok, displayName: "Grok", snapshot: snapshot)], + requestedDays: 7, + now: now) + let row = try #require(dashboard.groups.first?.providers.first) + + #expect(section.hintLine == "Public xAI list-price estimate · not a bill.") + #expect(row.totalCost == 0.0023) + #expect(row.costDisclaimer == "Public xAI list-price estimate · not a bill.") } @Test(.enabled( diff --git a/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift b/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift index 975570b63f..e4cb18604e 100644 --- a/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift +++ b/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift @@ -54,12 +54,14 @@ struct OpenCodexUsageFanOutTests { #expect(snapshots[.codex]?.last30DaysTokens == 40) } - @Test func `bare xai model prices from the injected xai catalog`() throws { + @Test func `bare xai model remains token only with an injected xai catalog`() throws { let catalog = try Self.pricingCatalog() let snapshot = try Self.pricingSnapshot(provider: "xai", model: "grok-4.6", catalog: catalog) - let cost = try #require(snapshot.daily.first?.costUSD) - #expect(abs(cost - 0.0023) < 0.000000000001) + #expect(snapshot.daily.first?.totalTokens == 1100) + #expect(snapshot.daily.first?.costUSD == nil) + #expect(snapshot.daily.first?.unpricedRequestCount == 1) + #expect(snapshot.last30DaysCostUSD == nil) } @Test func `bare openai model keeps its pre qualification catalog price`() throws { diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index ff31b7ddc1..d53bf0f7ea 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -3821,7 +3821,7 @@ struct ProviderArchitectureGatekeeperTests { anchor: "static let codexModelsDevProviderID = \"openai\"", expectedProviderIDs: ["deepseek", "openai", "opencode", "xai"], expectedReferenceCount: 5, - expectedReferenceFingerprint: ["openai@0", "deepseek@7", "openai@10", "opencode@11", "xai@14"], + expectedReferenceFingerprint: ["openai@0", "deepseek@3", "openai@6", "opencode@7", "xai@13"], reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift", diff --git a/docs/grok.md b/docs/grok.md index ac4e9f91e4..c7090c471d 100644 --- a/docs/grok.md +++ b/docs/grok.md @@ -134,7 +134,7 @@ The grok.com billing gRPC-web endpoint remains a best-effort fallback. - Aggregates the recorded per-turn token usage, model breakdown, request count, and timestamps. Public xAI list prices provide a non-billed cost estimate. - Reads only a bounded tail of each growing JSONL file, caps individual records - and retained parsed turns, and reports history as incomplete if a bound is hit. + and retained parsed turns, bounds session-tree discovery, and reports history as incomplete if a bound is hit. - Uses `signals.json` only as a metadata fallback for sessions with no completed turns; context-window occupancy is never counted as consumed tokens. @@ -264,7 +264,8 @@ CodexBar aggregates these into a `GrokLocalSessionSummary` (session count, actua tokens, last session time, primary model, and local-day buckets) over the requested window, up to 365 days. The reader streams a bounded tail of each file, limits a single JSONL record to 1 MiB, and retains at most 20,000 recent turns per file. A -scan considers at most 256 recent sessions, 256 MiB, and 100,000 turns; the +scan visits at most 4,096 session-tree entries, then considers at most 256 recent +sessions, 256 MiB, and 100,000 turns; the process-wide LRU parse cache retains at most 64 files or 50,000 turns. If a bound drops history, the resulting snapshot is marked incomplete instead of presenting partial totals as complete. `signals.json` contributes model/session metadata only From a11f47fb57d294c28cbbb3b8537522f8d309a696 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Mon, 24 Aug 2026 22:08:30 -0700 Subject: [PATCH 17/34] Refresh Grok fallback after preserved failures --- Sources/CodexBar/UsageStore+Refresh.swift | 26 +++--- Sources/CodexBar/UsageStore+TokenCost.swift | 15 ++-- .../CodexBarTests/CostUsagePricingTests.swift | 9 +- .../GrokLocalSessionScannerTests.swift | 89 +++++++++++++++++++ .../GrokStatusMenuFallbackTests.swift | 23 ++++- .../ProviderArchitectureGatekeeperTests.swift | 18 ++-- 6 files changed, 145 insertions(+), 35 deletions(-) diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index b46598bda9..982b8d9091 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -1334,17 +1334,20 @@ extension UsageStore { attempts: [ProviderFetchAttempt], context: ProviderRefreshOutcomeContext) async { - // Provider-specific by design: Grok's local fallback scans off the main thread when remote billing fails. - let grokLocalFallback: CostUsageTokenSnapshot? = if provider == .grok { - try? await self.loadGrokLocalTokenSnapshot(historyDays: SpendDashboardSource.scanDays) - } else { - nil - } guard !Task.isCancelled else { return } let shouldNotifyPermissionPrompt = Self.isPermissionPromptWaiting(error) await MainActor.run { guard self.isCurrentProviderRefreshGeneration(provider, generation: context.generation) else { return } self.diagnostics[provider.instanceID] = nil + // Provider-specific by design: local ~/.grok/sessions tokens remain readable and + // continue advancing after every remote billing failure, including transient failures + // that preserve the prior provider snapshot. + if provider == .grok { + Task { @MainActor [weak self] in + await self?.scanAndPublishGrokLocalTokenSnapshot( + historyDays: GrokLocalSessionScanner.maximumLookbackDays) + } + } let restoredClaudeHistory = self.prepareClaudeHistoryFallback( provider: provider, usesConsumerAutoPipeline: context.claudeUsesConsumerAutoPipeline, @@ -1460,15 +1463,8 @@ extension UsageStore { self.errors[provider.instanceID] = error.localizedDescription if !preservesPriorData, !preservesClaudeWebSessionFailure { self.snapshots.removeValue(forKey: provider.instanceID) - // Provider-specific by design: local ~/.grok/sessions tokens remain readable - // when the remote billing probe fails. - if provider == .grok { - if let local = grokLocalFallback { - self.publishTokenSnapshot(local, for: provider) - } else { - self.clearTokenSnapshot(for: provider) - } - } else if Self.tokenCostRequiresProviderSnapshot(provider) { + // Provider-specific by design: Grok already published its independent local scan above. + if provider != .grok, Self.tokenCostRequiresProviderSnapshot(provider) { self.clearTokenSnapshot(for: provider) } } diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 07c2a260d2..ee6a5a72b0 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -640,23 +640,22 @@ extension UsageStore { historyDays: Int? = nil) -> CostUsageTokenSnapshot? { - if let projected = self.tokenSnapshot( + let projected = self.tokenSnapshot( fromProviderSnapshot: snapshot, provider: provider, historyDays: historyDays) - { - return projected - } // Provider-specific by design: Grok's remote probe may fail while its local session scan still - // publishes a valid estimate. Keep this fallback scoped to live consumers so account override - // cards cannot inherit provider-level data from a different context. + // publishes a newer valid estimate. Keep this selection scoped to live consumers so account + // override cards cannot inherit provider-level data from a different context. guard provider == .grok, let published = self.tokenSnapshotPublicationForCurrentProviderConfig(for: provider)?.snapshot - else { return nil } + else { return projected } let windowDays = historyDays ?? self.settings.costUsageHistoryDays - return published.narrowed( + let narrowedPublished = published.narrowed( toHistoryDays: windowDays, calendar: self.settings.costUsageBucketCalendar) + guard let projected else { return narrowedPublished } + return narrowedPublished.updatedAt > projected.updatedAt ? narrowedPublished : projected } nonisolated static func tokenCostNoDataMessage(for provider: UsageProvider) -> String { diff --git a/Tests/CodexBarTests/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift index 6f24c055b1..cb6c7415c4 100644 --- a/Tests/CodexBarTests/CostUsagePricingTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingTests.swift @@ -488,12 +488,9 @@ struct CostUsagePricingTests { modelsDevCacheRoot: root) // Public API Fast rates are 2x Standard for GPT-5.6. - let expectedSol = 2.02 - let expectedTerra = 0.808 - let expectedLuna = 0.0808 - #expect(abs((sol ?? 0) - expectedSol) < 1e-12) - #expect(abs((terra ?? 0) - expectedTerra) < 1e-12) - #expect(abs((luna ?? 0) - expectedLuna) < 1e-12) + #expect(abs((sol ?? 0) - 2.02) < 1e-12) + #expect(abs((terra ?? 0) - 0.808) < 1e-12) + #expect(abs((luna ?? 0) - 0.0808) < 1e-12) } @Test diff --git a/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift b/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift index 92abce7e57..e97913cead 100644 --- a/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift +++ b/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift @@ -691,6 +691,95 @@ struct GrokLocalSessionScannerTests: GrokLocalSessionScannerTestSupport { #expect(fallbackScanCount == 2) } + @MainActor + @Test + func `preservable remote failure rescans and selects newer local tokens`() async throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let firstTurnAt = try self.localDate(day: 20, hour: 19, minute: 30) + let updates = fixture.session.appendingPathComponent("updates.jsonl") + let firstTurn = self.turn( + timestamp: firstTurnAt, + usage: self.singleModelUsage(input: 70, output: 7)) + try self.writeUpdates( + [firstTurn], + to: updates, + modificationDate: firstTurnAt.addingTimeInterval(60)) + + let catalog = try Self.catalog() + let priorScanAt = firstTurnAt.addingTimeInterval(120) + let priorLocal = try #require(GrokLocalSessionScanner.summarize( + env: ["GROK_HOME": fixture.root.path], + lookbackDays: GrokLocalSessionScanner.maximumLookbackDays, + now: priorScanAt, + modelsDevCatalog: catalog) + .toCostUsageTokenSnapshot(historyDays: GrokLocalSessionScanner.maximumLookbackDays)) + let settings = testSettingsStore(suiteName: "GrokLocalSessionScannerTests-preservable-failure") + let metadata = ProviderDescriptorRegistry.descriptor(for: .grok).metadata + settings.setProviderEnabled(provider: .grok, metadata: metadata, enabled: true) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: ["GROK_HOME": fixture.root.path]) + let retainedRemote = UsageSnapshot( + primary: nil, + secondary: nil, + costUsage: priorLocal, + updatedAt: priorScanAt) + store.snapshots[UsageProvider.grok.instanceID] = retainedRemote + + let secondTurnAt = firstTurnAt.addingTimeInterval(180) + let secondTurn = self.turn( + timestamp: secondTurnAt, + usage: self.singleModelUsage(input: 20, output: 3)) + try self.writeUpdates( + [firstTurn, secondTurn], + to: updates, + modificationDate: secondTurnAt.addingTimeInterval(60)) + let refreshedScanAt = secondTurnAt.addingTimeInterval(120) + var scanCount = 0 + store._test_grokLocalTokenScannerOverride = { historyDays in + scanCount += 1 + return GrokLocalSessionScanner.summarize( + env: ["GROK_HOME": fixture.root.path], + lookbackDays: historyDays, + now: refreshedScanAt, + modelsDevCatalog: catalog) + .toCostUsageTokenSnapshot(historyDays: historyDays) + } + store._test_providerFetchOutcomeOverride = { provider in + #expect(provider == .grok) + return ProviderFetchOutcome(result: .failure(URLError(.timedOut)), attempts: []) + } + + await store.refreshProvider(.grok) + for _ in 0..<100 { + if store.tokenSnapshotPublicationForCurrentProviderConfig(for: .grok)? + .snapshot?.last30DaysTokens == 100 + { + break + } + await Task.yield() + } + + #expect(scanCount == 1) + #expect(store.snapshots[UsageProvider.grok.instanceID]?.costUsage?.last30DaysTokens == 77) + let selected = store.tokenSnapshotForLiveProviderConsumer( + fromProviderSnapshot: store.snapshots[UsageProvider.grok.instanceID], + provider: .grok) + #expect(selected?.last30DaysTokens == 100) + #expect(selected?.updatedAt == refreshedScanAt) + + await store.refreshProvider(.grok) + for _ in 0..<100 { + if scanCount >= 2 { break } + await Task.yield() + } + #expect(scanCount == 2) + } + @Test func `local scan clock wins over a stale remote snapshot`() throws { let calendar = Calendar.current diff --git a/Tests/CodexBarTests/GrokStatusMenuFallbackTests.swift b/Tests/CodexBarTests/GrokStatusMenuFallbackTests.swift index b628b7876f..da857eaf19 100644 --- a/Tests/CodexBarTests/GrokStatusMenuFallbackTests.swift +++ b/Tests/CodexBarTests/GrokStatusMenuFallbackTests.swift @@ -5,7 +5,7 @@ import Testing extension StatusMenuTests { @Test - func `grok live menu consumers use published local fallback without remote snapshot`() throws { + func `grok live menu consumers prefer newer published local tokens over stale remote tokens`() throws { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -44,6 +44,27 @@ extension StatusMenuTests { modelBreakdowns: nil), ], updatedAt: now), provider: .grok) + let staleAt = now.addingTimeInterval(-60) + store.snapshots[UsageProvider.grok.instanceID] = UsageSnapshot( + primary: nil, + secondary: nil, + costUsage: CostUsageTokenSnapshot( + sessionTokens: 11, + sessionCostUSD: 0.01, + last30DaysTokens: 11, + last30DaysCostUSD: 0.01, + daily: [ + CostUsageDailyReport.Entry( + date: formatter.string(from: staleAt), + inputTokens: nil, + outputTokens: nil, + totalTokens: 11, + costUSD: 0.01, + modelsUsed: ["grok-4"], + modelBreakdowns: nil), + ], + updatedAt: staleAt), + updatedAt: staleAt) let controller = StatusItemController( store: store, diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index d53bf0f7ea..81ada45422 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1157,7 +1157,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 1502, + line: 1498, anchor: "let currentAccount = self.uniqueTokenAccount(provider: .claude, accountID: fetchedAccount.id),", expectedProviderIDs: ["claude"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), @@ -3016,7 +3016,15 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 1352, + line: 1345, + anchor: "if provider == .grok {", + expectedProviderIDs: ["claude", "gemini", "grok"], + expectedReferenceCount: 3, + expectedReferenceFingerprint: ["grok@0", "gemini@10", "claude@22"], + reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), + AllowedProviderConstruct( + path: "Sources/CodexBar/UsageStore+Refresh.swift", + line: 1355, anchor: "if provider == .gemini, Self.isGeminiConsumerTierDeprecationError(error) {", expectedProviderIDs: ["claude", "gemini"], expectedReferenceCount: 2, @@ -3024,7 +3032,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 1396, + line: 1399, anchor: "if provider == .claude,", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3032,7 +3040,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 1412, + line: 1415, anchor: "if provider == .claude,", expectedProviderIDs: ["claude"], expectedReferenceCount: 5, @@ -3040,7 +3048,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 1503, + line: 1499, anchor: "cached.cacheKey == self.tokenAccountSnapshotCacheKey(provider: .claude, account: currentAccount)", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, From cdcfcf9f41a1a90117bc12e67d2218e5c0084c74 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Mon, 24 Aug 2026 23:10:56 -0700 Subject: [PATCH 18/34] Use fresh Grok data in spend dashboard --- .../CodexBar/SpendDashboardController.swift | 21 +++--- .../ProviderArchitectureGatekeeperTests.swift | 2 +- .../SpendDashboardGrokFreshnessTests.swift | 68 +++++++++++++++++++ 3 files changed, 78 insertions(+), 13 deletions(-) create mode 100644 Tests/CodexBarTests/SpendDashboardGrokFreshnessTests.swift diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index 9b7ce0a141..297044479a 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -293,13 +293,13 @@ enum SpendDashboardSource { // Provider-specific by design: Grok local session tokens are independent of the // remote billing snapshot, so a failed probe still publishes readable logs. if provider == .grok { - let grokSnapshot = if let usage = store.snapshot(for: .grok) { - store.tokenSnapshot( - fromProviderSnapshot: usage, + let remote = store.snapshot(for: .grok) + let publication = store.tokenSnapshotPublicationForCurrentProviderConfig(for: .grok) + let grokSnapshot = if remote != nil || publication != nil { + store.tokenSnapshotForLiveProviderConsumer( + fromProviderSnapshot: remote, provider: .grok, historyDays: Self.scanDays) - } else if let published = store.tokenSnapshotPublicationForCurrentProviderConfig(for: .grok) { - published.snapshot } else { await store.scanAndPublishGrokLocalTokenSnapshot(historyDays: Self.scanDays) } @@ -876,13 +876,10 @@ enum SpendDashboardSource { { // Provider-specific by design: a failed Grok probe publishes its detached local scan. if provider == .grok { - if let usage = store.snapshot(for: .grok) { - return store.tokenSnapshot( - fromProviderSnapshot: usage, - provider: .grok, - historyDays: self.scanDays) - } - return publication.snapshot + return store.tokenSnapshotForLiveProviderConsumer( + fromProviderSnapshot: store.snapshot(for: .grok), + provider: .grok, + historyDays: self.scanDays) } if UsageStore.tokenCostRequiresProviderSnapshot(provider), let usage = store.snapshot(for: provider.instanceID), diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 81ada45422..2e1051d7dd 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -2446,7 +2446,7 @@ struct ProviderArchitectureGatekeeperTests { "codex@0", "grok@3", "grok@4", - "grok@7", + "grok@5", "grok@9", "grok@16", "grok@17", diff --git a/Tests/CodexBarTests/SpendDashboardGrokFreshnessTests.swift b/Tests/CodexBarTests/SpendDashboardGrokFreshnessTests.swift new file mode 100644 index 0000000000..737795d212 --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardGrokFreshnessTests.swift @@ -0,0 +1,68 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct SpendDashboardGrokFreshnessTests { + @Test + func `dashboard capture prefers newer local publication over preserved remote snapshot`() async throws { + let settings = testSettingsStore(suiteName: "SpendDashboardGrokFreshnessTests-preserved-remote") + settings.costUsageEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .grok) + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + let now = Date() + let staleRemote = Self.snapshot(tokens: 77, cost: 0.77, updatedAt: now.addingTimeInterval(-60)) + let newerLocal = Self.snapshot(tokens: 100, cost: 1, updatedAt: now) + store._setSnapshotForTesting( + UsageSnapshot(primary: nil, secondary: nil, costUsage: staleRemote, updatedAt: staleRemote.updatedAt), + provider: .grok) + store._setTokenSnapshotForTesting(newerLocal, provider: .grok) + + let request = await SpendDashboardSource.makeRequest( + settings: settings, + store: store, + mode: .captureOnly, + now: now) + let captured = try #require(request.capturedInputs.first(where: { $0.provider == .grok })) + + #expect(captured.snapshot.last30DaysTokens == 100) + #expect(captured.snapshot.last30DaysCostUSD == 1) + #expect(captured.snapshot.updatedAt == now) + } + + private static func snapshot( + tokens: Int, + cost: Double, + updatedAt: Date) -> CostUsageTokenSnapshot + { + let date = Calendar.current.dateComponents([.year, .month, .day], from: updatedAt) + let day = String(format: "%04d-%02d-%02d", date.year ?? 1970, date.month ?? 1, date.day ?? 1) + return CostUsageTokenSnapshot( + sessionTokens: tokens, + sessionCostUSD: cost, + last30DaysTokens: tokens, + last30DaysCostUSD: cost, + currencyCode: "USD", + daily: [ + CostUsageDailyReport.Entry( + date: day, + inputTokens: nil, + outputTokens: nil, + totalTokens: tokens, + costUSD: cost, + modelsUsed: ["grok-4"], + modelBreakdowns: nil), + ], + updatedAt: updatedAt) + } +} From b8bfe9a48d56f42231c5e2b93ca9cba8dfae42e4 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Tue, 25 Aug 2026 14:16:49 -0700 Subject: [PATCH 19/34] Fix Grok rebase integration Rebase onto current main and reconcile the surfaces it moved: - Route the Grok local summary through the injectable `localSummary`/`cliVersion` seams #3237 introduced, keeping the 365-day lookback and the models.dev pricing refresh in the injected defaults rather than at the call sites. - Regenerate the Codex parser hash and record main's `21f10143afe00c55` as a compatible predecessor; the Grok-only parser additions leave persisted Codex rows unchanged. Drop the stale branch-internal predecessor entry. - Re-anchor the provider-architecture gatekeeper suppressions and allowlists to the line numbers main's Spend dashboard and usage store now sit at. - Anchor the unstubbed Grok publish test to the real clock instead of a fixed calendar day, which had drifted outside its own seven-day window. - Move the xAI pricing-fingerprint test next to the other Grok pricing tests so `CostUsagePricingTests.swift` stays byte-identical to main and inside the file- and type-length limits. --- .../Grok/GrokLocalSessionScanner.swift | 28 ++++++++ .../Vendored/CostUsage/CostUsageStore.swift | 1 - .../CodexBarTests/CostUsagePricingTests.swift | 71 ++---------------- Tests/CodexBarTests/CostUsageStoreTests.swift | 1 - .../GrokCostUsagePricingTests.swift | 72 +++++++++++++++++++ .../GrokLocalSessionScannerTests.swift | 8 ++- .../GrokTokenSnapshotProjectionTests.swift | 16 ++--- .../ProviderArchitectureGatekeeperTests.swift | 44 ++++-------- 8 files changed, 132 insertions(+), 109 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift index 38a1544a37..929a2478c5 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift @@ -219,6 +219,8 @@ private final class GrokLocalSessionParseCache: @unchecked Sendable { private var entries: [String: Entry] = [:] private var fileDecodeCount = 0 private var jsonDecodeCount = 0 + private var fileDecodeCountByPath: [String: Int] = [:] + private var jsonDecodeCountByPath: [String: Int] = [:] private var accessOrdinal: UInt64 = 0 func turns( @@ -247,6 +249,9 @@ private final class GrokLocalSessionParseCache: @unchecked Sendable { defer { self.lock.unlock() } self.fileDecodeCount += 1 self.jsonDecodeCount += decoded.jsonDecodeCount + let metricsPath = URL(fileURLWithPath: path).resolvingSymlinksInPath().path + self.fileDecodeCountByPath[metricsPath, default: 0] += 1 + self.jsonDecodeCountByPath[metricsPath, default: 0] += decoded.jsonDecodeCount guard decoded.cacheable else { return decoded.batch } if var entry = self.entries[path], entry.identity == identity { entry.accessOrdinal = self.nextAccessOrdinal() @@ -292,12 +297,31 @@ private final class GrokLocalSessionParseCache: @unchecked Sendable { jsonDecodeCount: self.jsonDecodeCount) } + func metrics(pathPrefix: String) -> GrokLocalSessionParseCacheMetrics { + self.lock.lock() + defer { self.lock.unlock() } + let resolvedPrefix = URL(fileURLWithPath: pathPrefix).resolvingSymlinksInPath().path + let descendantPrefix = resolvedPrefix.hasSuffix("/") ? resolvedPrefix : "\(resolvedPrefix)/" + let belongsToPrefix: (String) -> Bool = { path in + path == resolvedPrefix || path.hasPrefix(descendantPrefix) + } + return GrokLocalSessionParseCacheMetrics( + fileDecodeCount: self.fileDecodeCountByPath + .filter { belongsToPrefix($0.key) } + .reduce(0) { $0 + $1.value }, + jsonDecodeCount: self.jsonDecodeCountByPath + .filter { belongsToPrefix($0.key) } + .reduce(0) { $0 + $1.value }) + } + func reset() { self.lock.lock() defer { self.lock.unlock() } self.entries.removeAll() self.fileDecodeCount = 0 self.jsonDecodeCount = 0 + self.fileDecodeCountByPath.removeAll() + self.jsonDecodeCountByPath.removeAll() self.accessOrdinal = 0 } @@ -621,6 +645,10 @@ public enum GrokLocalSessionScanner { self.parseCache.metrics() } + static func parseCacheMetricsForTesting(pathPrefix: String) -> GrokLocalSessionParseCacheMetrics { + self.parseCache.metrics(pathPrefix: pathPrefix) + } + static func resetParseCacheForTesting() { self.parseCache.reset() } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift index ce0793eb8a..9c58d94867 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift @@ -98,7 +98,6 @@ actor CostUsageStore { "c6c46a376ba16304", // 0.55.1 scheduler transition; rows and scoped retained reports are unchanged. "dd19ffa2dcfa8d47", // Current main before report-window scoping; persisted rows unchanged. "8050a4faf4fddb96", // PR base before retained-report persistence; parsed rows unchanged. - "64c5844b8ea170a6", // #3135 review fixes leave persisted Codex rows unchanged. "cfd84d13ad7d4cfa", // 0.55.x scan scheduling and progress bookkeeping; persisted rows unchanged. "3c984b655688593f", // xAI pricing and row-ownership evidence only; persisted Codex rows unchanged. "98da5914d2f6a9cd", // Pushed PR producer before retry signaling; persisted rows unchanged. diff --git a/Tests/CodexBarTests/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift index cb6c7415c4..58b4d13663 100644 --- a/Tests/CodexBarTests/CostUsagePricingTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingTests.swift @@ -348,68 +348,6 @@ struct CostUsagePricingTests { #expect(withoutKey != withEmptyKey) } - @Test - func `xai catalog changes use a separate fingerprint from Codex caches`() throws { - let first = try Self.modelsDevArtifact(""" - { - "openai": { - "id": "openai", - "models": { - "gpt-5.6-sol": { - "id": "gpt-5.6-sol", - "cost": { "input": 5, "output": 30 } - } - } - }, - "xai": { - "id": "xai", - "models": { - "grok-4.6": { - "id": "grok-4.6", - "cost": { "input": 2, "output": 6 } - } - } - } - } - """) - let xaiPriceChanged = try Self.modelsDevArtifact(""" - { - "openai": { - "id": "openai", - "models": { - "gpt-5.6-sol": { - "id": "gpt-5.6-sol", - "cost": { "input": 5, "output": 30 } - } - } - }, - "xai": { - "id": "xai", - "models": { - "grok-4.6": { - "id": "grok-4.6", - "cost": { "input": 3, "output": 7 } - } - } - } - } - """) - - let firstCodexKey = CostUsagePricingKey.codex(modelsDevArtifact: first, formulaVersion: 1) - let changedCodexKey = CostUsagePricingKey.codex(modelsDevArtifact: xaiPriceChanged, formulaVersion: 1) - let firstXAIKey = CostUsagePricingKey.codex( - modelsDevArtifact: first, - formulaVersion: 1, - modelsDevProviderIDs: CostUsagePricing.xaiModelsDevProviderIDs) - let changedXAIKey = CostUsagePricingKey.codex( - modelsDevArtifact: xaiPriceChanged, - formulaVersion: 1, - modelsDevProviderIDs: CostUsagePricing.xaiModelsDevProviderIDs) - - #expect(firstCodexKey == changedCodexKey) - #expect(firstXAIKey != changedXAIKey) - } - @Test func `codex pricing fingerprint records API fast USD definition`() { let fingerprint = CostUsagePricing.codexBuiltInPricingFingerprint() @@ -488,9 +426,12 @@ struct CostUsagePricingTests { modelsDevCacheRoot: root) // Public API Fast rates are 2x Standard for GPT-5.6. - #expect(abs((sol ?? 0) - 2.02) < 1e-12) - #expect(abs((terra ?? 0) - 0.808) < 1e-12) - #expect(abs((luna ?? 0) - 0.0808) < 1e-12) + let expectedSol = 2.02 + let expectedTerra = 0.808 + let expectedLuna = 0.0808 + #expect(abs((sol ?? 0) - expectedSol) < 1e-12) + #expect(abs((terra ?? 0) - expectedTerra) < 1e-12) + #expect(abs((luna ?? 0) - expectedLuna) < 1e-12) } @Test diff --git a/Tests/CodexBarTests/CostUsageStoreTests.swift b/Tests/CodexBarTests/CostUsageStoreTests.swift index 8c3dc25780..2d60ae95c8 100644 --- a/Tests/CodexBarTests/CostUsageStoreTests.swift +++ b/Tests/CodexBarTests/CostUsageStoreTests.swift @@ -1046,7 +1046,6 @@ extension CostUsageStoreTests { "c6c46a376ba16304", "dd19ffa2dcfa8d47", "8050a4faf4fddb96", - "64c5844b8ea170a6", "cfd84d13ad7d4cfa", "3c984b655688593f", "98da5914d2f6a9cd", diff --git a/Tests/CodexBarTests/GrokCostUsagePricingTests.swift b/Tests/CodexBarTests/GrokCostUsagePricingTests.swift index 04865abf75..fbfa20ae2f 100644 --- a/Tests/CodexBarTests/GrokCostUsagePricingTests.swift +++ b/Tests/CodexBarTests/GrokCostUsagePricingTests.swift @@ -330,3 +330,75 @@ struct GrokCostUsagePricingTests: GrokLocalSessionScannerTestSupport { #expect(projected365?.daily.map(\.date) == [olderDay, recentDay]) } } + +extension GrokCostUsagePricingTests { + @Test + func `xai catalog changes use a separate fingerprint from Codex caches`() throws { + let first = try Self.modelsDevArtifact(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-5.6-sol": { + "id": "gpt-5.6-sol", + "cost": { "input": 5, "output": 30 } + } + } + }, + "xai": { + "id": "xai", + "models": { + "grok-4.6": { + "id": "grok-4.6", + "cost": { "input": 2, "output": 6 } + } + } + } + } + """) + let xaiPriceChanged = try Self.modelsDevArtifact(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-5.6-sol": { + "id": "gpt-5.6-sol", + "cost": { "input": 5, "output": 30 } + } + } + }, + "xai": { + "id": "xai", + "models": { + "grok-4.6": { + "id": "grok-4.6", + "cost": { "input": 3, "output": 7 } + } + } + } + } + """) + + let firstCodexKey = CostUsagePricingKey.codex(modelsDevArtifact: first, formulaVersion: 1) + let changedCodexKey = CostUsagePricingKey.codex(modelsDevArtifact: xaiPriceChanged, formulaVersion: 1) + let firstXAIKey = CostUsagePricingKey.codex( + modelsDevArtifact: first, + formulaVersion: 1, + modelsDevProviderIDs: CostUsagePricing.xaiModelsDevProviderIDs) + let changedXAIKey = CostUsagePricingKey.codex( + modelsDevArtifact: xaiPriceChanged, + formulaVersion: 1, + modelsDevProviderIDs: CostUsagePricing.xaiModelsDevProviderIDs) + + #expect(firstCodexKey == changedCodexKey) + #expect(firstXAIKey != changedXAIKey) + } + + private static func modelsDevArtifact(_ json: String) throws -> ModelsDevCacheArtifact { + let catalog = try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8)) + return ModelsDevCacheArtifact( + version: ModelsDevCache.artifactVersion, + fetchedAt: Date(timeIntervalSince1970: 0), + catalog: catalog) + } +} diff --git a/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift b/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift index e97913cead..aba9158967 100644 --- a/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift +++ b/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift @@ -494,7 +494,7 @@ struct GrokLocalSessionScannerTests: GrokLocalSessionScannerTestSupport { let summary = try self.summarize(fixture: fixture, now: turnAt.addingTimeInterval(120)) let projected = try #require(summary.toCostUsageTokenSnapshot( historyDays: GrokLocalSessionScanner.maximumLookbackDays)) - let warmMetrics = GrokLocalSessionScanner.parseCacheMetricsForTesting() + let warmMetrics = GrokLocalSessionScanner.parseCacheMetricsForTesting(pathPrefix: fixture.root.path) let usage = UsageSnapshot( primary: nil, secondary: nil, @@ -514,7 +514,7 @@ struct GrokLocalSessionScannerTests: GrokLocalSessionScannerTestSupport { #expect(result?.historyDays == 7) #expect(result?.daily == projected.daily) #expect(result?.last30DaysTokens == projected.last30DaysTokens) - #expect(GrokLocalSessionScanner.parseCacheMetricsForTesting() == warmMetrics) + #expect(GrokLocalSessionScanner.parseCacheMetricsForTesting(pathPrefix: fixture.root.path) == warmMetrics) } @MainActor @@ -576,7 +576,9 @@ struct GrokLocalSessionScannerTests: GrokLocalSessionScannerTestSupport { func `missing remote snapshot scans and publishes local tokens then clears empty data`() async throws { let fixture = try self.makeFixture() defer { try? FileManager.default.removeItem(at: fixture.root) } - let turnAt = try self.localDate(day: 20, hour: 19, minute: 30) + // The unstubbed publish path scans against the real clock, so anchor the fixture to now + // instead of a fixed calendar day that eventually falls outside the seven-day window. + let turnAt = Date().addingTimeInterval(-3600) let updates = fixture.session.appendingPathComponent("updates.jsonl") let firstTurn = self.turn(timestamp: turnAt, usage: self.singleModelUsage(input: 70, output: 7)) try self.writeUpdates( diff --git a/Tests/CodexBarTests/GrokTokenSnapshotProjectionTests.swift b/Tests/CodexBarTests/GrokTokenSnapshotProjectionTests.swift index 4e9c40c78e..71da2715b8 100644 --- a/Tests/CodexBarTests/GrokTokenSnapshotProjectionTests.swift +++ b/Tests/CodexBarTests/GrokTokenSnapshotProjectionTests.swift @@ -4,7 +4,7 @@ import Testing @testable import CodexBar @MainActor -struct GrokTokenSnapshotProjectionTests { +struct GrokTokenSnapshotProjectionTests: GrokLocalSessionScannerTestSupport { @Test func `menu projections reuse published grok session data after the session tree disappears`() async throws { let root = FileManager.default.temporaryDirectory @@ -14,20 +14,16 @@ struct GrokTokenSnapshotProjectionTests { for index in 0..<192 { let directory = sessions.appendingPathComponent("session-\(index)", isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let file = directory.appendingPathComponent("signals.json") - try JSONSerialization.data(withJSONObject: [ - "contextTokensUsed": 5, - "totalTokensBeforeCompaction": 2, - "primaryModelId": "grok-4.6", - "modelsUsed": ["grok-4.6"], - ]).write(to: file) - try FileManager.default.setAttributes([.modificationDate: now], ofItemAtPath: file.path) + try self.writeUpdates( + [self.turn(timestamp: now, usage: self.singleModelUsage(input: 5, output: 2))], + to: directory.appendingPathComponent("updates.jsonl"), + modificationDate: now) } let store = Self.makeStore(environment: ["GROK_HOME": root.path]) let published = try #require(await store.loadGrokLocalTokenSnapshot(historyDays: 30)) #expect(published.last30DaysTokens == 1344) - #expect(published.last30DaysRequests == nil) + #expect(published.last30DaysRequests == 192) let providerSnapshot = UsageSnapshot( primary: nil, diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 2e1051d7dd..4571e8bd3f 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -959,55 +959,55 @@ struct ProviderArchitectureGatekeeperTests { reason: "This observation touchpoint reads a fixed provider field so UI invalidation tracks that setting."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 432, + line: 438, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 434, + line: 440, anchor: "modelProviderName: ProviderDescriptorRegistry.descriptor(for: .codex).metadata.displayName,", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 520, + line: 526, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 523, + line: 529, anchor: "modelProviderName: ProviderDescriptorRegistry.descriptor(for: .codex)", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 603, + line: 609, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 647, + line: 653, anchor: "let providerName = store.metadata(for: .codex).displayName", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 1693, + line: 1698, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This OpenCodex enrichment descriptor maps the canonical source back to the Codex family."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 1722, + line: 1727, anchor: "if providerID == UsageProvider.codex.rawValue {", expectedProviderIDs: ["codex"], reason: "This publication projection expands the fixed Codex provider family into its account sources."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 1739, + line: 1744, anchor: "if sourceID.hasPrefix(\"codex:\") { return .codex }", expectedProviderIDs: ["codex"], reason: "This publication projection maps stable Codex account source IDs back to their provider family."), @@ -2406,7 +2406,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 636, + line: 642, anchor: "(providers.contains(.codex) && settings.codexLocalSessionCostLedgerEnabled)", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2455,7 +2455,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 698, + line: 704, anchor: "if providers.contains(.codex) {", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2463,7 +2463,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 726, + line: 732, anchor: "if providers.contains(.codex) {", expectedProviderIDs: ["codex"], expectedReferenceCount: 3, @@ -2471,7 +2471,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 1766, + line: 1771, anchor: "guard input.provider == .codex,", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3022,14 +3022,6 @@ struct ProviderArchitectureGatekeeperTests { expectedReferenceCount: 3, expectedReferenceFingerprint: ["grok@0", "gemini@10", "claude@22"], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), - AllowedProviderConstruct( - path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 1355, - anchor: "if provider == .gemini, Self.isGeminiConsumerTierDeprecationError(error) {", - expectedProviderIDs: ["claude", "gemini"], - expectedReferenceCount: 2, - expectedReferenceFingerprint: ["gemini@0", "claude@12"], - reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", line: 1399, @@ -3290,18 +3282,12 @@ struct ProviderArchitectureGatekeeperTests { "openrouter@12", "xai@14", "grok@16", - "grok@27", - "mistral@27", - "openai@27", - "opencodego@27", - "openrouter@27", - "xai@27", - "grok@28", + "grok@26", ], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 574, + line: 632, anchor: "self.tokenFailureGates[.codex]?.reset()", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 2, From be567ee71bec32063634b6c977ff586775227f79 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Thu, 27 Aug 2026 17:27:11 -0700 Subject: [PATCH 20/34] Restore the Grok usage changelog entry --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 228e687fe9..b057583795 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -143,6 +143,9 @@ ### Security - CLI install: isolate helper validation and administrator commands from inherited shell functions and startup hooks, while preserving the existing approval flow and install locations (#3205, #3217). +### Usage & Spend +- Grok: count completed-turn usage from bounded local CLI session-log scans instead of context-window occupancy, and show the result as a clearly labeled, non-billed public xAI list-price estimate; OpenCodex xAI history remains token-only without request-time credential provenance (#3135). Thanks @olddonkey! + ## 0.55.1 — 2026-08-25 ### Highlights From 74dd816cc1ed24867e26493974c5fcdea9572f77 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Sat, 29 Aug 2026 18:14:57 -0700 Subject: [PATCH 21/34] Move the Grok changelog entry into the 0.56.1 section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.56.0 shipped without this change, so the entry belongs in the open `0.56.1 — Unreleased` section rather than the released one. --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b057583795..62a750a579 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,6 +115,9 @@ - Distinguish OpenCode-backed Codex OAuth quota from unsupported OpenCode session cost imports, preserving provider and account boundaries (investigated alongside #3273). Thanks @pedrommone! - Document existing z.ai credit quotas and explain how to configure independent provider widgets. +### Usage & Spend +- Grok: count completed-turn usage from bounded local CLI session-log scans instead of context-window occupancy, and show the result as a clearly labeled, non-billed public xAI list-price estimate; OpenCodex xAI history remains token-only without request-time credential provenance (#3135). Thanks @olddonkey! + ## 0.56.0 — 2026-08-28 ### Added @@ -143,9 +146,6 @@ ### Security - CLI install: isolate helper validation and administrator commands from inherited shell functions and startup hooks, while preserving the existing approval flow and install locations (#3205, #3217). -### Usage & Spend -- Grok: count completed-turn usage from bounded local CLI session-log scans instead of context-window occupancy, and show the result as a clearly labeled, non-billed public xAI list-price estimate; OpenCodex xAI history remains token-only without request-time credential provenance (#3135). Thanks @olddonkey! - ## 0.55.1 — 2026-08-25 ### Highlights From 7eecb4e5ebaf131f769bc6f2b83b51a3735d96ea Mon Sep 17 00:00:00 2001 From: olddonkey Date: Sat, 29 Aug 2026 18:14:57 -0700 Subject: [PATCH 22/34] Address the ClawSweeper P2 findings on the Grok scan - Queue the refreshable Grok summary through `CostUsageScanExecutor`. It called the synchronous corpus scanner inline, so the probe and descriptor callers ran a potentially multi-minute scan on the cooperative pool the executor exists to protect. A cancelled scan now reports unestablished coverage rather than an authoritative zero. - Derive the Grok lookback cutoff from the local start of day minus `historyDays - 1` so the scan window matches the inclusive local-day window `narrowed(toHistoryDays:)` renders, instead of collecting a partial extra day consumers discard. Each fix carries a regression that was confirmed to fail without it. --- .../Grok/GrokLocalSessionScanner.swift | 52 ++++++-- .../GrokLocalSessionScannerTests.swift | 112 ++++++++++++++++++ 2 files changed, 151 insertions(+), 13 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift index 929a2478c5..f15b59f24e 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift @@ -406,13 +406,11 @@ public enum GrokLocalSessionScanner { /// scan waits for the initial attempt so a successful refresh is reflected in the snapshot that callers publish. public static func summarizeRequestingPricingRefresh( env: [String: String] = ProcessInfo.processInfo.environment, - fileManager: FileManager = .default, lookbackDays: Int = defaultLookbackDays, now: Date = .init()) async -> GrokLocalSessionSummary { await self.summarizeRequestingPricingRefresh( env: env, - fileManager: fileManager, lookbackDays: lookbackDays, now: now, modelsDevCacheRoot: nil) @@ -423,7 +421,6 @@ public enum GrokLocalSessionScanner { static func summarizeRequestingPricingRefresh( env: [String: String], - fileManager: FileManager = .default, lookbackDays: Int = defaultLookbackDays, now: Date = .init(), modelsDevCacheRoot: URL?, @@ -437,15 +434,37 @@ public enum GrokLocalSessionScanner { } else { await requestPricingRefresh() } - return self.summarize( - env: env, - fileManager: fileManager, - lookbackDays: lookbackDays, - now: now, - pricing: PricingContext( - modelsDevCatalog: nil, - modelsDevCacheRoot: modelsDevCacheRoot, - customPricing: .empty)) + let pricing = PricingContext( + modelsDevCatalog: nil, + modelsDevCacheRoot: modelsDevCacheRoot, + customPricing: .empty) + // A corpus scan is synchronous and can run for minutes. `CostUsageScanExecutor` exists to keep + // exactly that work off the cooperative pool, so this path must queue through it rather than + // calling the scanner inline and stalling menu work alongside other scans. + do { + return try await CostUsageScanExecutor.run { checkCancellation in + try checkCancellation() + let summary = Self.summarize( + env: env, + fileManager: .default, + lookbackDays: lookbackDays, + now: now, + pricing: pricing) + try checkCancellation() + return summary + } + } catch { + // Callers rely on this fallback always returning a value. Report the window as + // unestablished so a cancelled scan is never presented as an authoritative zero. + return GrokLocalSessionSummary( + sessionCount: 0, + totalTokens: 0, + lastSessionAt: nil, + primaryModel: nil, + models: [], + scannedAt: now, + historyCoverageIsEstablished: false) + } } /// Walk `~/.grok/sessions///updates.jsonl` and aggregate completed turns. @@ -520,7 +539,14 @@ public enum GrokLocalSessionScanner { var visitedCachePaths: Set = [] defer { self.parseCache.retainEntries(at: visitedCachePaths) } let calendar = Calendar.current - let lookbackCutoff = calendar.date(byAdding: .day, value: -max(0, lookbackDays), to: now) ?? now + // Consumers window this history with `narrowed(toHistoryDays:)`, which is an inclusive local-day + // range ending today. Derive the same boundary here so the first displayed day is scanned whole + // and no extra partial day is collected for consumers to discard. + let startOfToday = calendar.startOfDay(for: now) + let lookbackCutoff = calendar.date( + byAdding: .day, + value: -max(0, lookbackDays - 1), + to: startOfToday) ?? startOfToday guard let sessionSelection = self.recentSessionPaths( root: root, fileManager: fileManager, diff --git a/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift b/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift index aba9158967..74d60245bc 100644 --- a/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift +++ b/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift @@ -883,3 +883,115 @@ private final class GrokModelsDevTrackingTransport: ModelsDevHTTPTransport, @unc private enum GrokModelsDevTrackingError: Error { case failed } + +private final class GrokScanExecutorGate: @unchecked Sendable { + private let lock = NSLock() + private let releaseSignal = DispatchSemaphore(value: 0) + private var entered = false + private var waiter: CheckedContinuation? + + func enter() { + self.lock.lock() + let first = !self.entered + self.entered = true + let waiter = self.waiter + self.waiter = nil + self.lock.unlock() + waiter?.resume() + if first { _ = self.releaseSignal.wait(timeout: .now() + 5) } + } + + func waitUntilInside() async { + await withCheckedContinuation { continuation in + self.lock.lock() + let entered = self.entered + if !entered { self.waiter = continuation } + self.lock.unlock() + if entered { continuation.resume() } + } + } + + func release() { + self.releaseSignal.signal() + } +} + +private actor GrokScanCompletionFlag { + private(set) var isFinished = false + func markFinished() { + self.isFinished = true + } +} + +extension GrokLocalSessionScannerTests { + /// The refreshable entry point used to call the synchronous corpus scanner inline, so its callers + /// ran a potentially multi-minute scan on the cooperative pool. It must queue through the shared + /// serial executor instead; occupying that executor therefore has to hold this scan back. + @Test + func `refreshable scan queues through the shared corpus scan executor`() async throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let cacheRoot = fixture.root.appendingPathComponent("models-dev", isDirectory: true) + try FileManager.default.createDirectory(at: cacheRoot, withIntermediateDirectories: true) + let now = try self.localDate(day: 10, hour: 12) + let turnAt = try self.localDate(day: 10, hour: 9) + try self.writeUpdates( + [self.turn(timestamp: turnAt, usage: self.singleModelUsage(input: 100, output: 10))], + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: turnAt.addingTimeInterval(60)) + + let gate = GrokScanExecutorGate() + let blocker = Task { try await CostUsageScanExecutor.run { _ in gate.enter() } } + await gate.waitUntilInside() + + let finished = GrokScanCompletionFlag() + let scan = Task { () -> GrokLocalSessionSummary in + let summary = await GrokLocalSessionScanner.summarizeRequestingPricingRefresh( + env: ["GROK_HOME": fixture.root.path], + lookbackDays: 7, + now: now, + modelsDevCacheRoot: cacheRoot) {} + await finished.markFinished() + return summary + } + // An inline scan of this fixture finishes in microseconds, so poll well past that: staying + // unfinished for the whole window is only possible if the scan is queued behind the blocker. + for _ in 0..<60 { + if await finished.isFinished { break } + try await Task.sleep(nanoseconds: 5_000_000) + } + #expect(await finished.isFinished == false) + + gate.release() + try await blocker.value + let summary = await scan.value + #expect(summary.totalTokens == 110) + } + + /// Consumers render this history as an inclusive local-day window. Deriving the scan cutoff from the + /// current instant instead collected a partial extra day that those consumers then discarded. + @Test + func `history window starts at the first local day rather than the current instant`() throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let now = try self.localDate(day: 10, hour: 12) + let firstDisplayedDay = try self.localDate(day: 8, hour: 3) + let dayBeforeWindow = try self.localDate(day: 7, hour: 20) + try self.writeUpdates( + [ + self.turn(timestamp: dayBeforeWindow, usage: self.singleModelUsage(input: 500, output: 50)), + self.turn(timestamp: firstDisplayedDay, usage: self.singleModelUsage(input: 100, output: 10)), + ], + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: now.addingTimeInterval(-60)) + + let summary = GrokLocalSessionScanner.summarize( + env: ["GROK_HOME": fixture.root.path], + lookbackDays: 3, + now: now) + + // A three-day window ending today covers days 8-10; the day-7 turn is outside it. + #expect(summary.totalTokens == 110) + #expect(summary.daily.count == 1) + } +} From cb33abba7edbd467b96c5dff1ff97f7d56709331 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Sat, 29 Aug 2026 18:14:57 -0700 Subject: [PATCH 23/34] Redo the parser-hash bookkeeping for the rebased main - Regenerate `CodexParserHash.value` to `69eee1ccce7ed69c`. - Record main's `50a2507f4c10d080` in `CostUsageStore.compatiblePredecessorParserHashes` and in its exact-set assertion. --- .../CodexBarCore/Generated/CodexParserHash.generated.swift | 2 +- Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift | 4 +++- Tests/CodexBarTests/CostUsageStoreTests.swift | 4 +++- Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 92d963d3df..9a7e62b741 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 = "d2e66225d0b33672" + static let value = "38c892ecd2ee2447" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift index 9c58d94867..128b1e943a 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift @@ -80,6 +80,8 @@ actor CostUsageStore { parserHash: CodexParserHash.value) static let cacheGeneration = "sqlite:\(CostUsageStore.schemaVersion)" static let compatiblePredecessorParserHashes: Set = [ + "d2e66225d0b33672", // Current main; Grok pricing additions preserve native rows and checkpoints. + "b974e5782bad3f29", // Previous Grok branch; native persisted rows remain compatible. "2590d36e1cc4a2ea", // Lazy token history reads preserve persisted rows and scan checkpoints. "edd0a6ad56c0e4e7", // Astra pricing changes report costs without changing native rows or scan checkpoints. "f043ae98075c8e4d", // Retained scan-range scheduling preserves native rows, checkpoints, and reports. @@ -99,12 +101,12 @@ actor CostUsageStore { "dd19ffa2dcfa8d47", // Current main before report-window scoping; persisted rows unchanged. "8050a4faf4fddb96", // PR base before retained-report persistence; parsed rows unchanged. "cfd84d13ad7d4cfa", // 0.55.x scan scheduling and progress bookkeeping; persisted rows unchanged. - "3c984b655688593f", // xAI pricing and row-ownership evidence only; persisted Codex rows unchanged. "98da5914d2f6a9cd", // Pushed PR producer before retry signaling; persisted rows unchanged. "43609cc56f76a003", // 0.49.3 request-tier pricing; persisted row shape unchanged. "b975eb705f905b9a", // 0.49.0-0.49.2 SQLite producer with compatible rows. "47144baa8daccf52", // This branch changes only scan scheduling, discovery, and persistence bookkeeping. "2d17f4981b78d07f", // Persisted priority-turn cursor; parser and persisted row shape unchanged. + "3c984b655688593f", // 0.54.x row-ownership evidence fix; parser and persisted row shape unchanged. "5f8507161b23757c", // 0.54.2 tokscale parity + priority evidence; persisted row shape unchanged. ] static let incompatibleRetainedReportPredecessorParserHashes: Set = [ diff --git a/Tests/CodexBarTests/CostUsageStoreTests.swift b/Tests/CodexBarTests/CostUsageStoreTests.swift index 2d60ae95c8..edc58a6cbf 100644 --- a/Tests/CodexBarTests/CostUsageStoreTests.swift +++ b/Tests/CodexBarTests/CostUsageStoreTests.swift @@ -1028,6 +1028,8 @@ extension CostUsageStoreTests { let fixture = try StoreFixture() defer { fixture.remove() } #expect(CostUsageStore.compatiblePredecessorParserHashes == [ + "d2e66225d0b33672", + "b974e5782bad3f29", "2590d36e1cc4a2ea", "edd0a6ad56c0e4e7", "f043ae98075c8e4d", @@ -1047,12 +1049,12 @@ extension CostUsageStoreTests { "dd19ffa2dcfa8d47", "8050a4faf4fddb96", "cfd84d13ad7d4cfa", - "3c984b655688593f", "98da5914d2f6a9cd", "43609cc56f76a003", "b975eb705f905b9a", "47144baa8daccf52", "2d17f4981b78d07f", + "3c984b655688593f", "5f8507161b23757c", ]) let predecessorVersion = CostUsageStore.combinedSchemaVersion( diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 4571e8bd3f..61cf5fa79a 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -2487,7 +2487,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardModel.swift", - line: 1093, + line: 1099, anchor: "guard provider == .mistral || provider == .openrouter || provider == .xai else { return displayCalendar }", expectedProviderIDs: ["mistral", "openrouter", "xai"], expectedReferenceCount: 3, From fc8dbe678b4dabf324b01e5b49e8dbccabc2ddd4 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Tue, 1 Sep 2026 11:32:26 -0700 Subject: [PATCH 24/34] Price Grok turns from the spend the CLI recorded #3345 measured `costUsdTicks` on an independent 934-turn corpus and established its divisor as 1e10. My own 2026-08-21 backtest had concluded 1e9, because that quotient came out to exactly 1.7x the public card on a 25-turn sample drawn entirely from an xAI promotional window. Re-running that arithmetic on this branch's corpus reproduces both readings to four decimals, so the divisor was wrong rather than the measurement. The consequence is not a rounding artifact. The six turns behind this branch's gated proof publish $2.181282 reconstructed from the public card; the spend the CLI recorded for the same turns is $0.370818. #3345 reports 6.66x across a whole promotional period. Read `costUsdTicks` when a record has it, since it already carries the price tier and any promotional rate, and fall back to the public card only where it does not. Fallback entries are counted as estimated rather than priced, and the window publishes `.vendorMetered`, `.listPriceEstimate`, or `.mixed` accordingly, so the dashboard and menu name the source instead of asserting one. A record that omits the field or reports 0 has no recorded spend; #3345 measured that at 2 of 934 turns. The fixtures no longer inject a sentinel tick value by default, so the existing suites keep exercising the fallback path. Note for the maintainer: the 2026-08-21 owner ruling chose the public card over `costUsdTicks` for Grok, and my incorrect divisor was part of the evidence behind it. That ruling should be revisited on these numbers rather than treated as overridden by this commit. --- CHANGELOG.md | 2 +- .../Grok/GrokLocalSessionScanner.swift | 79 +++++++++++++++-- .../Grok/GrokProviderDescriptor.swift | 6 +- .../CostHistoryChartMenuViewTests.swift | 2 +- .../GrokCostUsagePricingTests.swift | 10 +-- .../GrokLocalSessionScannerTestSupport.swift | 23 +++-- .../GrokLocalSessionScannerTests.swift | 87 ++++++++++++++++++- .../GrokXAISpendCatalogTests.swift | 20 +++-- docs/grok.md | 9 +- 9 files changed, 205 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62a750a579..9cccdd930f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -116,7 +116,7 @@ - Document existing z.ai credit quotas and explain how to configure independent provider widgets. ### Usage & Spend -- Grok: count completed-turn usage from bounded local CLI session-log scans instead of context-window occupancy, and show the result as a clearly labeled, non-billed public xAI list-price estimate; OpenCodex xAI history remains token-only without request-time credential provenance (#3135). Thanks @olddonkey! +- Grok: count completed-turn usage from bounded local CLI session-log scans instead of context-window occupancy, and price it from the spend the CLI recorded, falling back to clearly labeled public xAI list prices where it recorded none; OpenCodex xAI history remains token-only without request-time credential provenance (#3135, #3345). Thanks @olddonkey and @initH271! ## 0.56.0 — 2026-08-28 diff --git a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift index f15b59f24e..b868480648 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift @@ -61,6 +61,8 @@ public struct GrokLocalSessionSummary: Sendable { public let daily: [GrokLocalDailyBucket] public let scannedAt: Date public let historyCoverageIsEstablished: Bool + /// Which source produced the daily costs: the CLI's recorded spend, the public card, or both. + public let costProvenance: CostProvenance public init( sessionCount: Int, @@ -70,7 +72,8 @@ public struct GrokLocalSessionSummary: Sendable { models: [String], daily: [GrokLocalDailyBucket] = [], scannedAt: Date = .init(), - historyCoverageIsEstablished: Bool = true) + historyCoverageIsEstablished: Bool = true, + costProvenance: CostProvenance = .listPriceEstimate) { self.sessionCount = sessionCount self.totalTokens = totalTokens @@ -80,9 +83,11 @@ public struct GrokLocalSessionSummary: Sendable { self.daily = daily self.scannedAt = scannedAt self.historyCoverageIsEstablished = historyCoverageIsEstablished + self.costProvenance = costProvenance } - /// Local tokens priced at public API list rates; this is an estimate, not a Grok bill. + /// Local turns priced from the spend the Grok CLI recorded, falling back to public API list rates for + /// entries it did not record. Neither figure is a Grok bill. public func toCostUsageTokenSnapshot(historyDays: Int) -> CostUsageTokenSnapshot? { let entries = self.daily.map { bucket in CostUsageDailyReport.Entry( @@ -114,7 +119,7 @@ public struct GrokLocalSessionSummary: Sendable { last30DaysRequests: self.daily.reduce(0) { $0 + $1.requestCount }, historyDays: historyDays, historyCoverageIsEstablished: self.historyCoverageIsEstablished, - costProvenance: .listPriceEstimate, + costProvenance: self.costProvenance, daily: entries, updatedAt: self.scannedAt) } @@ -181,6 +186,8 @@ private struct GrokParsedTokenUsage: Sendable { let cacheCreationTokens: Int let reasoningTokens: Int let modelCalls: Int? + /// Spend the Grok CLI recorded for this usage, in ticks. `nil` when the record omits it or reports 0. + let costUsdTicks: Int? } private struct GrokParsedTurn: Sendable { @@ -396,8 +403,21 @@ public enum GrokLocalSessionScanner { private struct ScanAggregation { var modelCounts: [String: Int] = [:] var daily: [String: MutableDailyBucket] = [:] + /// Whether any entry was priced from the CLI's recorded spend, and whether any fell back to the + /// public card. Both can be true, which publishes a mixed window rather than claiming either source. + var sawRecordedCost = false + var sawEstimatedCost = false } + /// Divisor that turns `costUsdTicks` into USD. + /// + /// Established on two independent corpora: 934 turns in #3345 and the 6-turn corpus behind this branch's + /// gated proof, both landing on `1e10` to four decimals. An earlier reading of this branch used `1e9`, + /// which happened to equal `1.7 x` the public card on a sample drawn entirely from an xAI promotional + /// window; the field carries that promotional rate, so reconstructing the same turns from the public card + /// overstated them by 5.9x. + static let costUsdTicksPerUSD = 1e10 + private static let parseCache = GrokLocalSessionParseCache() private static let turnCompletedNeedle = Data("turn_completed".utf8) @@ -463,7 +483,8 @@ public enum GrokLocalSessionScanner { primaryModel: nil, models: [], scannedAt: now, - historyCoverageIsEstablished: false) + historyCoverageIsEstablished: false, + costProvenance: .unknown) } } @@ -651,7 +672,18 @@ public enum GrokLocalSessionScanner { models: sortedModels, daily: buckets, scannedAt: now, - historyCoverageIsEstablished: historyCoverageIsEstablished) + historyCoverageIsEstablished: historyCoverageIsEstablished, + costProvenance: Self.provenance(aggregation: aggregation)) + } + + /// A window that priced nothing has no provenance to claim; one that used both sources is mixed. + private static func provenance(aggregation: ScanAggregation) -> CostProvenance { + switch (aggregation.sawRecordedCost, aggregation.sawEstimatedCost) { + case (true, true): .mixed + case (true, false): .vendorMetered + case (false, true): .listPriceEstimate + case (false, false): .unknown + } } public static func summarizeOffMainThread( @@ -694,7 +726,8 @@ public enum GrokLocalSessionScanner { lastSessionAt: nil, primaryModel: nil, models: [], - scannedAt: now) + scannedAt: now, + costProvenance: .unknown) } private static func fileIdentity(for url: URL) -> FileIdentity? { @@ -873,7 +906,16 @@ public enum GrokLocalSessionScanner { cachedReadTokens: max(0, self.integer(object["cachedReadTokens"]) ?? 0), cacheCreationTokens: max(0, self.integer(object["cacheCreationTokens"]) ?? 0), reasoningTokens: max(0, self.integer(object["reasoningTokens"]) ?? 0), - modelCalls: self.integer(object["modelCalls"])) + modelCalls: self.integer(object["modelCalls"]), + costUsdTicks: self.recordedCostTicks(object["costUsdTicks"])) + } + + /// `costUsdTicks` is the spend the CLI recorded for a turn, already carrying its price tier and any + /// promotional rate. A record that omits the field, or reports 0 as a small share of turns do, has no + /// recorded spend; those entries fall back to the public card. + private static func recordedCostTicks(_ value: Any?) -> Int? { + guard let ticks = self.integer(value), ticks > 0 else { return nil } + return ticks } private static func integer(_ value: Any?) -> Int? { @@ -927,7 +969,15 @@ public enum GrokLocalSessionScanner { if turn.modelUsage.isEmpty { let requests = self.requestCount(for: turn.usage) bucket.requestCount += requests - bucket.unpricedRequestCount += requests + // A turn with no per-model attribution still carries its own recorded spend; only the + // list-price path needs a SKU, so an unattributed turn is unpriced without recorded ticks. + if let recorded = turn.usage.costUsdTicks { + bucket.costUSD += Double(recorded) / Self.costUsdTicksPerUSD + bucket.hasPricedCost = true + aggregation.sawRecordedCost = true + } else { + bucket.unpricedRequestCount += requests + } } for (sku, usage) in turn.modelUsage { let requests = self.requestCount(for: usage) @@ -943,7 +993,16 @@ public enum GrokLocalSessionScanner { breakdown.totalTokens += usage.totalTokens breakdown.requestCount += requests - if let cost = self.costUSD( + // The CLI's recorded spend already carries the price tier and any promotional rate, so it wins + // over a reconstruction whenever the record has it. The public card stays the fallback. + if let recorded = usage.costUsdTicks { + let cost = Double(recorded) / Self.costUsdTicksPerUSD + breakdown.costUSD += cost + breakdown.hasPricedCost = true + bucket.costUSD += cost + bucket.hasPricedCost = true + aggregation.sawRecordedCost = true + } else if let cost = self.costUSD( sku: sku, usage: usage, pricingDate: turn.timestamp, @@ -953,6 +1012,8 @@ public enum GrokLocalSessionScanner { breakdown.hasPricedCost = true bucket.costUSD += cost bucket.hasPricedCost = true + bucket.estimatedRequestCount += requests + aggregation.sawEstimatedCost = true } else { bucket.unpricedRequestCount += requests } diff --git a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift index af3b82181c..1e68d49b74 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift @@ -87,12 +87,12 @@ public enum GrokProviderDescriptor { tokenCost: ProviderTokenCostConfig( supportsTokenCost: true, noDataMessage: { - "Grok totals come from local Grok CLI session logs. " - + "Costs are public list-price estimates, not a bill." + "Grok totals come from local Grok CLI session logs. Costs use the spend the CLI " + + "recorded, or public list prices where it recorded none. Neither is a bill." }, menuHintLines: [.estimate], showsHintInProviderDetails: true, - estimateDisclaimer: "Public xAI list-price estimate · not a bill.", + estimateDisclaimer: "Grok CLI-recorded spend, list price where unrecorded · not a bill.", chartEstimateDisclaimer: .estimate), pace: ProviderPaceCapability( resetWindowPace: .custom { window, now in diff --git a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift index d4b6954894..d1a1c2863c 100644 --- a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift +++ b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift @@ -227,7 +227,7 @@ struct CostHistoryChartMenuViewTests { == "Estimated from token usage · not a subscription bill") #expect( CostHistoryChartMenuView.estimateDisclaimer(provider: .grok) - == "Public xAI list-price estimate · not a bill.") + == "Grok CLI-recorded spend, list price where unrecorded · not a bill.") #expect(CostHistoryChartMenuView.estimateDisclaimer(provider: .claude) == nil) } diff --git a/Tests/CodexBarTests/GrokCostUsagePricingTests.swift b/Tests/CodexBarTests/GrokCostUsagePricingTests.swift index fbfa20ae2f..26ad9d60e1 100644 --- a/Tests/CodexBarTests/GrokCostUsagePricingTests.swift +++ b/Tests/CodexBarTests/GrokCostUsagePricingTests.swift @@ -6,7 +6,7 @@ import Testing @Suite(.serialized) struct GrokCostUsagePricingTests: GrokLocalSessionScannerTestSupport { @Test - func `completed turn reports exact tokens and public list price`() throws { + func `a turn without recorded spend reports exact tokens and the public list price`() throws { let fixture = try self.makeFixture() defer { try? FileManager.default.removeItem(at: fixture.root) } let turnAt = try self.localDate(day: 20, hour: 12) @@ -43,7 +43,7 @@ struct GrokCostUsagePricingTests: GrokLocalSessionScannerTestSupport { #expect(day.reasoningTokens == 20) #expect(day.totalTokens == 1100) #expect(day.requestCount == 1) - #expect(day.estimatedRequestCount == 0) + #expect(day.estimatedRequestCount == 1) #expect(abs((day.costUSD ?? 0) - expectedCost) < 0.000000000001) let snapshot = try #require(summary.toCostUsageTokenSnapshot(historyDays: 7)) @@ -51,9 +51,9 @@ struct GrokCostUsagePricingTests: GrokLocalSessionScannerTestSupport { #expect(abs((snapshot.sessionCostUSD ?? 0) - expectedCost) < 0.000000000001) #expect(abs((snapshot.last30DaysCostUSD ?? 0) - expectedCost) < 0.000000000001) #expect(snapshot.costProvenance == .listPriceEstimate) - #expect(snapshot.daily.first?.estimatedRequestCount == nil) - #expect(snapshot.daily.first?.coverageCounts.priced == 1) - #expect(snapshot.daily.first?.coverageCounts.estimated == 0) + #expect(snapshot.daily.first?.estimatedRequestCount == 1) + #expect(snapshot.daily.first?.coverageCounts.priced == 0) + #expect(snapshot.daily.first?.coverageCounts.estimated == 1) } @Test diff --git a/Tests/CodexBarTests/GrokLocalSessionScannerTestSupport.swift b/Tests/CodexBarTests/GrokLocalSessionScannerTestSupport.swift index fcf538b12c..2da671745a 100644 --- a/Tests/CodexBarTests/GrokLocalSessionScannerTestSupport.swift +++ b/Tests/CodexBarTests/GrokLocalSessionScannerTestSupport.swift @@ -56,13 +56,18 @@ extension GrokLocalSessionScannerTestSupport { ] } - func singleModelUsage(input: Int, output: Int) -> [String: Any] { + func singleModelUsage(input: Int, output: Int, costUsdTicks: Int? = nil) -> [String: Any] { self.usage( input: input, output: output, modelCalls: 1, + costUsdTicks: costUsdTicks, modelUsage: [ - "grok-4.6-build": self.modelUsage(input: input, output: output, modelCalls: 1), + "grok-4.6-build": self.modelUsage( + input: input, + output: output, + modelCalls: 1, + costUsdTicks: costUsdTicks), ]) } @@ -73,6 +78,7 @@ extension GrokLocalSessionScannerTestSupport { cacheCreation: Int = 0, reasoning: Int = 0, modelCalls: Int?, + costUsdTicks: Int? = nil, modelUsage: [String: [String: Any]]) -> [String: Any] { var result: [String: Any] = [ @@ -84,11 +90,15 @@ extension GrokLocalSessionScannerTestSupport { "reasoningTokens": reasoning, "modelUsage": modelUsage, "numTurns": modelCalls ?? 1, - "costUsdTicks": 999_999_999_999, ] if let modelCalls { result["modelCalls"] = modelCalls } + // Omitted by default so the existing suites keep exercising the public-card fallback; a test that + // wants the recorded-spend path asks for it explicitly. + if let costUsdTicks { + result["costUsdTicks"] = costUsdTicks + } return result } @@ -98,7 +108,8 @@ extension GrokLocalSessionScannerTestSupport { cachedRead: Int = 0, cacheCreation: Int = 0, reasoning: Int = 0, - modelCalls: Int?) -> [String: Any] + modelCalls: Int?, + costUsdTicks: Int? = nil) -> [String: Any] { var result: [String: Any] = [ "inputTokens": input, @@ -107,11 +118,13 @@ extension GrokLocalSessionScannerTestSupport { "cachedReadTokens": cachedRead, "cacheCreationTokens": cacheCreation, "reasoningTokens": reasoning, - "costUsdTicks": 999_999_999_999, ] if let modelCalls { result["modelCalls"] = modelCalls } + if let costUsdTicks { + result["costUsdTicks"] = costUsdTicks + } return result } diff --git a/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift b/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift index 74d60245bc..98e88b1a99 100644 --- a/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift +++ b/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift @@ -476,7 +476,9 @@ struct GrokLocalSessionScannerTests: GrokLocalSessionScannerTestSupport { #expect(day.requestCount == 1) #expect(day.unpricedRequestCount == 0) - #expect(snapshot.daily.first?.coverageCounts.priced == 1) + // The fixture records no spend, so the request is covered by the public-card fallback. + #expect(snapshot.daily.first?.coverageCounts.estimated == 1) + #expect(snapshot.daily.first?.coverageCounts.priced == 0) } @MainActor @@ -994,4 +996,87 @@ extension GrokLocalSessionScannerTests { #expect(summary.totalTokens == 110) #expect(summary.daily.count == 1) } + + /// The CLI's own figure already carries the price tier and any promotional rate, so it must win over a + /// public-card reconstruction. The fixture's tokens would reconstruct to a very different number. + @Test + func `recorded spend prices the turn instead of the public card`() throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let turnAt = try self.localDate(day: 20, hour: 12) + try self.writeUpdates( + [self.turn( + timestamp: turnAt, + usage: self.singleModelUsage(input: 1_000_000, output: 100_000, costUsdTicks: 3_708_179_400))], + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: turnAt.addingTimeInterval(60)) + + let summary = try self.summarize(fixture: fixture, now: turnAt.addingTimeInterval(120)) + let day = try #require(summary.daily.first) + let snapshot = try #require(summary.toCostUsageTokenSnapshot(historyDays: 7)) + + #expect(abs((day.costUSD ?? 0) - 0.37081794) < 0.000000000001) + #expect(day.estimatedRequestCount == 0) + #expect(day.unpricedRequestCount == 0) + #expect(summary.costProvenance == .vendorMetered) + #expect(snapshot.costProvenance == .vendorMetered) + #expect(snapshot.daily.first?.coverageCounts.priced == 1) + #expect(snapshot.daily.first?.coverageCounts.estimated == 0) + // The same tokens on the public tier-1 card come to $2.60, so the recorded figure is not a + // reconstruction that happens to agree. + let breakdown = try #require(day.modelBreakdowns.first) + #expect(abs((breakdown.costUSD ?? 0) - 0.37081794) < 0.000000000001) + } + + /// A record that reports 0 has no recorded spend to read, which is how a small share of turns arrive. + @Test + func `a zero tick record falls back to the public card`() throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let turnAt = try self.localDate(day: 20, hour: 12) + try self.writeUpdates( + [self.turn( + timestamp: turnAt, + usage: self.singleModelUsage(input: 1000, output: 100, costUsdTicks: 0))], + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: turnAt.addingTimeInterval(60)) + + let summary = try self.summarize(fixture: fixture, now: turnAt.addingTimeInterval(120)) + let day = try #require(summary.daily.first) + + #expect(day.estimatedRequestCount == 1) + #expect((day.costUSD ?? 0) > 0) + #expect(summary.costProvenance == .listPriceEstimate) + } + + /// A corpus that uses both sources must say so rather than claiming either one for the whole window. + @Test + func `a corpus mixing recorded and reconstructed turns publishes a mixed window`() throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let recordedAt = try self.localDate(day: 20, hour: 12) + let reconstructedAt = try self.localDate(day: 21, hour: 12) + try self.writeUpdates( + [ + self.turn( + timestamp: recordedAt, + usage: self.singleModelUsage(input: 1000, output: 100, costUsdTicks: 1_000_000_000)), + self.turn( + timestamp: reconstructedAt, + usage: self.singleModelUsage(input: 1000, output: 100)), + ], + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: reconstructedAt.addingTimeInterval(60)) + + let summary = try self.summarize(fixture: fixture, now: reconstructedAt.addingTimeInterval(120)) + let snapshot = try #require(summary.toCostUsageTokenSnapshot(historyDays: 7)) + let recordedDay = try #require(summary.daily.first { $0.estimatedRequestCount == 0 }) + let reconstructedDay = try #require(summary.daily.first { $0.estimatedRequestCount == 1 }) + + #expect(summary.costProvenance == .mixed) + #expect(snapshot.costProvenance == .mixed) + #expect(abs((recordedDay.costUSD ?? 0) - 0.1) < 0.000000000001) + #expect((reconstructedDay.costUSD ?? 0) > 0) + #expect(abs((reconstructedDay.costUSD ?? 0) - 0.1) > 0.000000000001) + } } diff --git a/Tests/CodexBarTests/GrokXAISpendCatalogTests.swift b/Tests/CodexBarTests/GrokXAISpendCatalogTests.swift index 332b07f2a6..e838f6775c 100644 --- a/Tests/CodexBarTests/GrokXAISpendCatalogTests.swift +++ b/Tests/CodexBarTests/GrokXAISpendCatalogTests.swift @@ -12,16 +12,18 @@ struct GrokXAISpendCatalogTests { #expect(grokTokenCost.supportsTokenCost) #expect(ProviderDescriptorRegistry.descriptor(for: .xai).tokenCost.supportsTokenCost) #expect(grokTokenCost.noDataMessage() == - "Grok totals come from local Grok CLI session logs. Costs are public list-price estimates, not a bill.") + "Grok totals come from local Grok CLI session logs. Costs use the spend the CLI recorded, " + + "or public list prices where it recorded none. Neither is a bill.") #expect(grokTokenCost.menuHintLines == [.estimate]) #expect(grokTokenCost.showsHintInProviderDetails) - #expect(grokTokenCost.estimateDisclaimer == "Public xAI list-price estimate · not a bill.") + #expect(grokTokenCost + .estimateDisclaimer == "Grok CLI-recorded spend, list price where unrecorded · not a bill.") #expect(grokTokenCost.chartEstimateDisclaimer == .estimate) } @MainActor @Test - func `populated Grok surfaces disclose that list price is not a bill`() throws { + func `populated Grok surfaces disclose that the cost is not a bill`() throws { let now = Date(timeIntervalSince1970: 1_787_587_200) let snapshot = CostUsageTokenSnapshot( sessionTokens: 1100, @@ -53,9 +55,9 @@ struct GrokXAISpendCatalogTests { now: now) let row = try #require(dashboard.groups.first?.providers.first) - #expect(section.hintLine == "Public xAI list-price estimate · not a bill.") + #expect(section.hintLine == "Grok CLI-recorded spend, list price where unrecorded · not a bill.") #expect(row.totalCost == 0.0023) - #expect(row.costDisclaimer == "Public xAI list-price estimate · not a bill.") + #expect(row.costDisclaimer == "Grok CLI-recorded spend, list price where unrecorded · not a bill.") } @Test(.enabled( @@ -76,7 +78,13 @@ struct GrokXAISpendCatalogTests { #expect(grokRow.totalTokens == snapshot.last30DaysTokens) #expect(model.tokenActivity.contains { $0.totalTokens != nil }) #expect(snapshot.historyDays == SpendDashboardSource.scanDays) - #expect(snapshot.costProvenance == .listPriceEstimate) + // Which source priced the corpus depends on what the CLI recorded, so assert the contract that + // holds for every corpus: a priced window names a source, an unpriced one claims none. + if pricedDayCount > 0 { + #expect([.vendorMetered, .mixed, .listPriceEstimate].contains(snapshot.costProvenance)) + } else { + #expect(snapshot.costProvenance == .unknown) + } #expect(pricedDayCount <= tokenDayCount) if tokenDayCount > 0 { if pricedDayCount > 0 { diff --git a/docs/grok.md b/docs/grok.md index c7090c471d..d587fbb4e6 100644 --- a/docs/grok.md +++ b/docs/grok.md @@ -132,7 +132,11 @@ The grok.com billing gRPC-web endpoint remains a best-effort fallback. `~/.grok/sessions///updates.jsonl` over the requested history window (up to 365 days). - Aggregates the recorded per-turn token usage, model breakdown, request count, - and timestamps. Public xAI list prices provide a non-billed cost estimate. + and timestamps. Cost comes from the `costUsdTicks` the CLI recorded for each turn + (ticks / 1e10 = USD), which already carries the price tier and any promotional + rate. Entries that record no ticks fall back to public xAI list prices and are + counted as estimated, so a window reports whether it was recorded, estimated, or + a mix of both. Neither figure is a Grok bill. - Reads only a bounded tail of each growing JSONL file, caps individual records and retained parsed turns, bounds session-tree discovery, and reports history as incomplete if a bound is hit. - Uses `signals.json` only as a metadata fallback for sessions with no completed @@ -274,7 +278,8 @@ when no completed turns are available and is also limited to 1 MiB. Those local daily token buckets also feed the shared Usage & Spend catalog so an enabled Grok subscription is counted instead of omitted. SuperGrok/X Premium+ credits remain a quota window on the usage bar; they are never converted into -dollars. Public xAI list-price dollars are shown only as a non-billed estimate. +dollars. Dollar figures come from the spend the CLI recorded, or from public xAI list +prices where it recorded none; neither is a bill. Local session scans run on the dedicated background usage-scan queue; menu cards and spend views reuse the already-published snapshot instead of walking the session directory whenever they render. From a2d85f7e6a4146f4a214d28fb0e24192944799be Mon Sep 17 00:00:00 2001 From: olddonkey Date: Tue, 1 Sep 2026 18:24:20 -0700 Subject: [PATCH 25/34] Derive narrowed Grok windows from the days they keep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClawSweeper found two window-projection defects that the 365-day Grok snapshot newly exposes, since a maximum-window snapshot is now narrowed per consumer. `grokLocalTokenSnapshot` recomputed tokens and requests from the retained days but copied the published cost total, so a 30-day menu view could render the 365-day dollar amount beside a 30-day token count. It now sums the retained rows. Both that projection and `CostUsageTokenSnapshot.narrowed(toHistoryDays:)` carried the snapshot-wide provenance into the derived window, so a window that excluded every recorded row still claimed recorded spend — newly reachable now that a Grok window can be mixed. The Grok projection derives the disclosure from the coverage counts of the days it kept, because its scanner counts a recorded turn as priced and a card fallback as estimated. The generic narrowing applies the existing `CostProvenance.forWindow` rule instead, which stops a costless window from claiming a provenance without guessing what the surviving rows of another provider mean. Each fix carries a regression confirmed to fail without it. --- .../Grok/UsageStore+GrokLocalSessions.swift | 27 +++++- Sources/CodexBarCore/CostUsageModels.swift | 25 ++++- .../GrokCostUsagePricingTests.swift | 4 + .../GrokTokenSnapshotProjectionTests.swift | 95 +++++++++++++++++-- 4 files changed, 140 insertions(+), 11 deletions(-) diff --git a/Sources/CodexBar/Providers/Grok/UsageStore+GrokLocalSessions.swift b/Sources/CodexBar/Providers/Grok/UsageStore+GrokLocalSessions.swift index e6bcec7067..37f2501fa7 100644 --- a/Sources/CodexBar/Providers/Grok/UsageStore+GrokLocalSessions.swift +++ b/Sources/CodexBar/Providers/Grok/UsageStore+GrokLocalSessions.swift @@ -21,20 +21,23 @@ extension UsageStore { guard !daily.isEmpty else { return nil } let tokens = daily.compactMap(\.totalTokens) let requests = daily.compactMap(\.requestCount) + // Tokens and requests are recomputed from the retained days, so the cost has to be too. Copying the + // published total would render the full 365-day amount beside a 30-day token count. + let costs = daily.compactMap(\.costUSD) return CostUsageTokenSnapshot( sessionTokens: published.sessionTokens, sessionCostUSD: published.sessionCostUSD, sessionRequests: published.sessionRequests, last30DaysTokens: tokens.isEmpty ? nil : tokens.reduce(0, +), - last30DaysCostUSD: published.last30DaysCostUSD, + last30DaysCostUSD: costs.isEmpty ? nil : costs.reduce(0, +), last30DaysRequests: requests.isEmpty ? nil : requests.reduce(0, +), currencyCode: published.currencyCode, historyDays: days, historyCoverageIsEstablished: published.historyCoverageIsEstablished && published.historyDays >= days, historyLabel: published.historyLabel, meteredCostUSD: published.meteredCostUSD, - costProvenance: published.costProvenance, + costProvenance: Self.grokWindowProvenance(published: published.costProvenance, daily: daily), credentialScopeFingerprint: published.credentialScopeFingerprint, daily: daily, projects: published.projects, @@ -50,6 +53,26 @@ extension UsageStore { return summary.toCostUsageTokenSnapshot(historyDays: historyDays) } + /// The disclosure has to describe the days this window kept. Grok's scanner counts a CLI-recorded turn + /// as priced and a public-card fallback as estimated, so the retained rows say which sources survived; + /// a window that kept no priced rows claims nothing. + private static func grokWindowProvenance( + published: CostProvenance, + daily: [CostUsageDailyReport.Entry]) -> CostProvenance + { + var counts = CostUsageCoverageCounts() + for entry in daily { + counts.merge(entry.coverageCounts) + } + guard daily.contains(where: { $0.costUSD != nil }) else { return .unknown } + switch (counts.priced > 0, counts.estimated > 0) { + case (true, true): return .mixed + case (true, false): return .vendorMetered + case (false, true): return .listPriceEstimate + case (false, false): return published + } + } + private static func grokLocalDayKey(for date: Date, calendar: Calendar) -> String? { let parts = calendar.dateComponents([.year, .month, .day], from: date) guard let year = parts.year, let month = parts.month, let day = parts.day else { return nil } diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index cd6ef816db..61cdd6c80d 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -197,7 +197,10 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { calendar: calendar, historyCoverageIsEstablished: self.historyCoverageIsEstablished, meteredCostUSD: days == self.historyDays ? self.meteredCostUSD : nil, - costProvenance: self.costProvenance, + costProvenance: Self.narrowedProvenance( + snapshot: self.costProvenance, + entries: entries, + includesMetered: days == self.historyDays && self.meteredCostUSD != nil), credentialScopeFingerprint: self.credentialScopeFingerprint, historyLabel: self.historyLabel, projects: self.projects, @@ -235,7 +238,10 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { historyCoverageIsEstablished: self.historyCoverageIsEstablished, historyLabel: self.historyLabel, meteredCostUSD: derived.meteredCostUSD, - costProvenance: self.costProvenance, + costProvenance: Self.narrowedProvenance( + snapshot: self.costProvenance, + entries: entries, + includesMetered: derived.meteredCostUSD != nil), credentialScopeFingerprint: self.credentialScopeFingerprint, daily: entries, projects: self.projects, @@ -244,6 +250,21 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { updatedAt: self.updatedAt) } + /// A narrowed window can exclude every priced row it inherited its disclosure from, so the derived + /// snapshot must describe the rows it kept. This is the same narrowing the window summary applies; it + /// deliberately does not re-derive which *kind* of cost the surviving rows carry, because per-row + /// coverage counts mean different things to different providers. + private static func narrowedProvenance( + snapshot: CostProvenance, + entries: [CostUsageDailyReport.Entry], + includesMetered: Bool) -> CostProvenance + { + CostProvenance.forWindow( + snapshot: snapshot, + hasWindowCosts: entries.contains { $0.costUSD != nil }, + includesMetered: includesMetered) + } + public func summary(forLastDays requestedDays: Int, calendar: Calendar = .current) -> CostUsageWindowSummary { let days = max(1, requestedDays) let today = calendar.startOfDay(for: self.updatedAt) diff --git a/Tests/CodexBarTests/GrokCostUsagePricingTests.swift b/Tests/CodexBarTests/GrokCostUsagePricingTests.swift index 26ad9d60e1..86ea7ca4bd 100644 --- a/Tests/CodexBarTests/GrokCostUsagePricingTests.swift +++ b/Tests/CodexBarTests/GrokCostUsagePricingTests.swift @@ -300,6 +300,10 @@ struct GrokCostUsagePricingTests: GrokLocalSessionScannerTestSupport { #expect(maximum.last30DaysTokens == 150) #expect(maximum.last30DaysCostUSD == 1.5) #expect(maximum.daily.map(\.date) == [olderDay, recentDay]) + // A window that kept a priced row keeps its disclosure; one that kept none claims nothing rather + // than inheriting the snapshot's. + #expect(narrowed.costProvenance == full.costProvenance) + #expect(full.narrowed(toHistoryDays: 1, calendar: calendar).costProvenance == .unknown) let store = UsageStore( fetcher: UsageFetcher(environment: [:]), diff --git a/Tests/CodexBarTests/GrokTokenSnapshotProjectionTests.swift b/Tests/CodexBarTests/GrokTokenSnapshotProjectionTests.swift index 71da2715b8..0ddc4c2ef1 100644 --- a/Tests/CodexBarTests/GrokTokenSnapshotProjectionTests.swift +++ b/Tests/CodexBarTests/GrokTokenSnapshotProjectionTests.swift @@ -122,30 +122,111 @@ struct GrokTokenSnapshotProjectionTests: GrokLocalSessionScannerTestSupport { environmentBase: environment) } + /// The projection recomputes tokens and requests from the days it keeps, so the cost has to follow. A + /// 30-day view rendering the 365-day dollar total beside a 30-day token count is the visible symptom. + @Test + func `grok projection recomputes window cost from the days it keeps`() throws { + let calendar = Calendar.current + let now = Date(timeIntervalSince1970: 1_787_079_600) + let recent = try #require(calendar.date(byAdding: .day, value: -2, to: now)) + let older = try #require(calendar.date(byAdding: .day, value: -120, to: now)) + let published = Self.snapshot( + daily: [ + Self.entry(date: Self.dayKey(older, calendar: calendar), tokens: 900, costUSD: 9), + Self.entry(date: Self.dayKey(recent, calendar: calendar), tokens: 100, costUSD: 1), + ], + updatedAt: now, + historyDays: 365, + costProvenance: .vendorMetered) + let store = Self.makeStore(environment: [:]) + + let narrowed = try #require(store.grokLocalTokenSnapshot( + from: UsageSnapshot(primary: nil, secondary: nil, costUsage: published, updatedAt: now), + historyDays: 30)) + + #expect(published.last30DaysCostUSD == 10) + #expect(narrowed.last30DaysTokens == 100) + #expect(narrowed.last30DaysCostUSD == 1) + } + + /// A narrowed window can drop every row of one kind, and its disclosure has to follow the rows it kept. + @Test + func `grok projection derives window provenance from the days it keeps`() throws { + let calendar = Calendar.current + let now = Date(timeIntervalSince1970: 1_787_079_600) + let recent = try #require(calendar.date(byAdding: .day, value: -2, to: now)) + let older = try #require(calendar.date(byAdding: .day, value: -120, to: now)) + let store = Self.makeStore(environment: [:]) + + let recordedRecently = Self.snapshot( + daily: [ + Self.entry( + date: Self.dayKey(older, calendar: calendar), + tokens: 900, + costUSD: 9, + estimatedRequestCount: 1), + Self.entry(date: Self.dayKey(recent, calendar: calendar), tokens: 100, costUSD: 1), + ], + updatedAt: now, + historyDays: 365, + costProvenance: .mixed) + let narrowedToRecorded = try #require(store.grokLocalTokenSnapshot( + from: UsageSnapshot(primary: nil, secondary: nil, costUsage: recordedRecently, updatedAt: now), + historyDays: 30)) + #expect(narrowedToRecorded.costProvenance == .vendorMetered) + + let estimatedRecently = Self.snapshot( + daily: [ + Self.entry(date: Self.dayKey(older, calendar: calendar), tokens: 900, costUSD: 9), + Self.entry( + date: Self.dayKey(recent, calendar: calendar), + tokens: 100, + costUSD: 1, + estimatedRequestCount: 1), + ], + updatedAt: now, + historyDays: 365, + costProvenance: .mixed) + let narrowedToEstimated = try #require(store.grokLocalTokenSnapshot( + from: UsageSnapshot(primary: nil, secondary: nil, costUsage: estimatedRecently, updatedAt: now), + historyDays: 30)) + #expect(narrowedToEstimated.costProvenance == .listPriceEstimate) + } + private static func snapshot( daily: [CostUsageDailyReport.Entry], - updatedAt: Date) -> CostUsageTokenSnapshot + updatedAt: Date, + historyDays: Int = 30, + costProvenance: CostProvenance = .unknown) -> CostUsageTokenSnapshot { - CostUsageTokenSnapshot( + let costs = daily.compactMap(\.costUSD) + return CostUsageTokenSnapshot( sessionTokens: daily.last?.totalTokens, sessionCostUSD: nil, last30DaysTokens: daily.compactMap(\.totalTokens).reduce(0, +), - last30DaysCostUSD: nil, - historyDays: 30, + last30DaysCostUSD: costs.isEmpty ? nil : costs.reduce(0, +), + historyDays: historyDays, + costProvenance: costProvenance, daily: daily, updatedAt: updatedAt) } - private static func entry(date: String, tokens: Int) -> CostUsageDailyReport.Entry { + private static func entry( + date: String, + tokens: Int, + costUSD: Double? = nil, + estimatedRequestCount: Int? = nil) -> CostUsageDailyReport.Entry + { CostUsageDailyReport.Entry( date: date, inputTokens: nil, outputTokens: nil, totalTokens: tokens, requestCount: 1, - costUSD: nil, + costUSD: costUSD, modelsUsed: ["grok-4.6"], - modelBreakdowns: nil) + modelBreakdowns: nil, + estimatedRequestCount: estimatedRequestCount) } private static func dayKey(_ date: Date, calendar: Calendar) -> String { From 4f0150bb1e3af72e1f0ad0f215d30325bd8596ea Mon Sep 17 00:00:00 2001 From: olddonkey Date: Fri, 4 Sep 2026 13:12:00 -0700 Subject: [PATCH 26/34] Preserve Grok cost sources in live history windows --- CHANGELOG.md | 6 +- .../Grok/UsageStore+GrokLocalSessions.swift | 27 ++++ Sources/CodexBar/UsageStore+TokenCost.swift | 12 +- .../GrokTokenSnapshotProjectionTests.swift | 72 +++++++++++ .../GrokWindowProvenanceProofTests.swift | 115 ++++++++++++++++++ .../ProviderArchitectureGatekeeperTests.swift | 2 +- docs/grok.md | 4 + 7 files changed, 225 insertions(+), 13 deletions(-) create mode 100644 Tests/CodexBarTests/GrokWindowProvenanceProofTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cccdd930f..0e94443b48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ - Menu bar: show an exhausted supported quota in automatic switcher progress instead of healthy weekly capacity, while preserving normal weekly progress and provider-specific quota pools (partial fix for #3349). Thanks @rwese! - Menu bar: reuse cached template images for single-line text-only custom layouts, preserving native highlighting, display scaling, spacing, and vertical adjustments; colored emoji, rich, stale, and high-contrast content retain their existing rendering (#3110). Thanks @thatlev! +### Usage & Spend +- Grok: count completed-turn usage from bounded local CLI session-log scans instead of context-window occupancy, and price it from the spend the CLI recorded, falling back to clearly labeled public xAI list prices where it recorded none; OpenCodex xAI history remains token-only without request-time credential provenance (#3135, #3345). Thanks @olddonkey and @initH271! + ## 0.56.5 — 2026-09-04 ### Highlights @@ -115,9 +118,6 @@ - Distinguish OpenCode-backed Codex OAuth quota from unsupported OpenCode session cost imports, preserving provider and account boundaries (investigated alongside #3273). Thanks @pedrommone! - Document existing z.ai credit quotas and explain how to configure independent provider widgets. -### Usage & Spend -- Grok: count completed-turn usage from bounded local CLI session-log scans instead of context-window occupancy, and price it from the spend the CLI recorded, falling back to clearly labeled public xAI list prices where it recorded none; OpenCodex xAI history remains token-only without request-time credential provenance (#3135, #3345). Thanks @olddonkey and @initH271! - ## 0.56.0 — 2026-08-28 ### Added diff --git a/Sources/CodexBar/Providers/Grok/UsageStore+GrokLocalSessions.swift b/Sources/CodexBar/Providers/Grok/UsageStore+GrokLocalSessions.swift index 37f2501fa7..ef80f9bdcb 100644 --- a/Sources/CodexBar/Providers/Grok/UsageStore+GrokLocalSessions.swift +++ b/Sources/CodexBar/Providers/Grok/UsageStore+GrokLocalSessions.swift @@ -53,6 +53,33 @@ extension UsageStore { return summary.toCostUsageTokenSnapshot(historyDays: historyDays) } + /// Generic window math does not know that Grok's priced rows are CLI-recorded spend. Apply the same + /// source-aware disclosure to live publications and scan results as to the remote-backed projection. + func narrowedGrokTokenSnapshot(_ published: CostUsageTokenSnapshot, historyDays: Int) -> CostUsageTokenSnapshot { + let narrowed = published.narrowed( + toHistoryDays: historyDays, + calendar: self.settings.costUsageBucketCalendar) + return CostUsageTokenSnapshot( + sessionTokens: narrowed.sessionTokens, + sessionCostUSD: narrowed.sessionCostUSD, + sessionRequests: narrowed.sessionRequests, + last30DaysTokens: narrowed.last30DaysTokens, + last30DaysCostUSD: narrowed.last30DaysCostUSD, + last30DaysRequests: narrowed.last30DaysRequests, + currencyCode: narrowed.currencyCode, + historyDays: narrowed.historyDays, + historyCoverageIsEstablished: narrowed.historyCoverageIsEstablished, + historyLabel: narrowed.historyLabel, + meteredCostUSD: narrowed.meteredCostUSD, + costProvenance: Self.grokWindowProvenance(published: published.costProvenance, daily: narrowed.daily), + credentialScopeFingerprint: narrowed.credentialScopeFingerprint, + daily: narrowed.daily, + projects: narrowed.projects, + sessions: narrowed.sessions, + hourly: narrowed.hourly, + updatedAt: narrowed.updatedAt) + } + /// The disclosure has to describe the days this window kept. Grok's scanner counts a CLI-recorded turn /// as priced and a public-card fallback as estimated, so the retained rows say which sources survived; /// a window that kept no priced rows claims nothing. diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index ee6a5a72b0..d795a5141c 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -531,9 +531,7 @@ extension UsageStore { let provider = UsageProvider.grok let requestedHistoryDays = min(max(1, historyDays), GrokLocalSessionScanner.maximumLookbackDays) if let task = self.grokLocalTokenScanTask { - return await task.value?.narrowed( - toHistoryDays: requestedHistoryDays, - calendar: self.settings.costUsageBucketCalendar) + return await task.value.map { self.narrowedGrokTokenSnapshot($0, historyDays: requestedHistoryDays) } } let environment = self.environmentBase @@ -578,9 +576,7 @@ extension UsageStore { self.grokLocalTokenScanTask = nil self.grokLocalTokenScanToken = nil } - return snapshot?.narrowed( - toHistoryDays: requestedHistoryDays, - calendar: self.settings.costUsageBucketCalendar) + return snapshot.map { self.narrowedGrokTokenSnapshot($0, historyDays: requestedHistoryDays) } } nonisolated static func tokenCostRequiresProviderSnapshot(_ provider: UsageProvider) -> Bool { @@ -651,9 +647,7 @@ extension UsageStore { let published = self.tokenSnapshotPublicationForCurrentProviderConfig(for: provider)?.snapshot else { return projected } let windowDays = historyDays ?? self.settings.costUsageHistoryDays - let narrowedPublished = published.narrowed( - toHistoryDays: windowDays, - calendar: self.settings.costUsageBucketCalendar) + let narrowedPublished = self.narrowedGrokTokenSnapshot(published, historyDays: windowDays) guard let projected else { return narrowedPublished } return narrowedPublished.updatedAt > projected.updatedAt ? narrowedPublished : projected } diff --git a/Tests/CodexBarTests/GrokTokenSnapshotProjectionTests.swift b/Tests/CodexBarTests/GrokTokenSnapshotProjectionTests.swift index 0ddc4c2ef1..16dac246a3 100644 --- a/Tests/CodexBarTests/GrokTokenSnapshotProjectionTests.swift +++ b/Tests/CodexBarTests/GrokTokenSnapshotProjectionTests.swift @@ -193,6 +193,78 @@ struct GrokTokenSnapshotProjectionTests: GrokLocalSessionScannerTestSupport { #expect(narrowedToEstimated.costProvenance == .listPriceEstimate) } + @Test(arguments: [false, true], [false, true]) + func `live Grok consumers retain the source of a narrowed mixed publication`( + estimatedRecently: Bool, + hasRemoteSnapshot: Bool) throws + { + let published = try Self.mixedSnapshot(estimatedRecently: estimatedRecently) + let store = Self.makeStore(environment: [:]) + store.publishTokenSnapshot(published, for: .grok) + let remote = hasRemoteSnapshot ? UsageSnapshot( + primary: nil, + secondary: nil, + costUsage: Self.snapshot(daily: published.daily, updatedAt: published.updatedAt.addingTimeInterval(-60)), + updatedAt: published.updatedAt.addingTimeInterval(-60)) : nil + + let selected = try #require(store.tokenSnapshotForLiveProviderConsumer( + fromProviderSnapshot: remote, + provider: .grok, + historyDays: 30)) + + #expect(selected.last30DaysTokens == 100) + #expect(selected.last30DaysCostUSD == 1) + #expect(selected.updatedAt == published.updatedAt) + #expect(selected.costProvenance == (estimatedRecently ? .listPriceEstimate : .vendorMetered)) + let dashboard = SpendDashboardModel.build( + inputs: [.init(provider: .grok, displayName: "Grok", snapshot: selected)], + requestedDays: 30, + now: selected.updatedAt) + #expect(dashboard.groups.first?.provenance == selected.costProvenance) + } + + @Test(arguments: [false, true]) + func `new and shared Grok scans retain the source of the requested window`(estimatedRecently: Bool) async throws { + let published = try Self.mixedSnapshot(estimatedRecently: estimatedRecently) + let store = Self.makeStore(environment: [:]) + let metadata = try #require(ProviderRegistry.shared.metadata[.grok]) + store.settings.setProviderEnabled(provider: .grok, metadata: metadata, enabled: true) + store._test_grokLocalTokenScannerOverride = { _ in published } + + let first = try #require(await store.scanAndPublishGrokLocalTokenSnapshot(historyDays: 30)) + #expect(first.costProvenance == (estimatedRecently ? .listPriceEstimate : .vendorMetered)) + #expect(first.last30DaysCostUSD == 1) + + store.grokLocalTokenScanTask = Task { published } + defer { store.grokLocalTokenScanTask = nil } + let shared = try #require(await store.scanAndPublishGrokLocalTokenSnapshot(historyDays: 30)) + #expect(shared.costProvenance == (estimatedRecently ? .listPriceEstimate : .vendorMetered)) + #expect(shared == first) + } + + private static func mixedSnapshot(estimatedRecently: Bool) throws -> CostUsageTokenSnapshot { + let calendar = Calendar.current + let now = Date(timeIntervalSince1970: 1_787_079_600) + let recent = try #require(calendar.date(byAdding: .day, value: -2, to: now)) + let older = try #require(calendar.date(byAdding: .day, value: -120, to: now)) + return Self.snapshot( + daily: [ + Self.entry( + date: Self.dayKey(older, calendar: calendar), + tokens: 900, + costUSD: 9, + estimatedRequestCount: estimatedRecently ? nil : 1), + Self.entry( + date: Self.dayKey(recent, calendar: calendar), + tokens: 100, + costUSD: 1, + estimatedRequestCount: estimatedRecently ? 1 : nil), + ], + updatedAt: now, + historyDays: 365, + costProvenance: .mixed) + } + private static func snapshot( daily: [CostUsageDailyReport.Entry], updatedAt: Date, diff --git a/Tests/CodexBarTests/GrokWindowProvenanceProofTests.swift b/Tests/CodexBarTests/GrokWindowProvenanceProofTests.swift new file mode 100644 index 0000000000..714a64d4b6 --- /dev/null +++ b/Tests/CodexBarTests/GrokWindowProvenanceProofTests.swift @@ -0,0 +1,115 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@MainActor +struct GrokWindowProvenanceProofTests: GrokLocalSessionScannerTestSupport { + @Test(arguments: [false, true]) + func `completed turn logs retain their cost source through the live window`(estimatedRecently: Bool) throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let now = Date() + let recent = try #require(Calendar.current.date(byAdding: .day, value: -2, to: now)) + let older = try #require(Calendar.current.date(byAdding: .day, value: -120, to: now)) + try self.writeUpdates( + [ + self.turn(timestamp: older, usage: self.singleModelUsage( + input: 1000, output: 0, costUsdTicks: estimatedRecently ? 10_000_000_000 : nil)), + self.turn(timestamp: recent, usage: self.singleModelUsage( + input: 1000, output: 0, costUsdTicks: estimatedRecently ? nil : 10_000_000_000)), + ], + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: now) + let summary = try GrokLocalSessionScanner.summarize( + env: ["GROK_HOME": fixture.root.path], + lookbackDays: 365, + now: now, + modelsDevCatalog: Self.catalog()) + let published = try #require(summary.toCostUsageTokenSnapshot(historyDays: 365)) + let store = Self.makeStore() + store.publishTokenSnapshot(published, for: .grok) + // An available billing snapshot without local costs must still consume the newer local publication. + let remote = UsageSnapshot(primary: nil, secondary: nil, updatedAt: now.addingTimeInterval(-60)) + let selected = try #require(store.tokenSnapshotForLiveProviderConsumer( + fromProviderSnapshot: remote, provider: .grok, historyDays: 30)) + let expectedSource: CostProvenance = estimatedRecently ? .listPriceEstimate : .vendorMetered + let expectedCost = estimatedRecently ? 0.002 : 1 + + #expect(published.costProvenance == .mixed) + #expect(selected.historyDays == 30) + #expect(selected.last30DaysTokens == 1000) + #expect(abs((selected.last30DaysCostUSD ?? 0) - expectedCost) < 1e-12) + #expect(selected.costProvenance == expectedSource) + try Self.verifySurfaces(selected, days: 30) + print("fixture_full_source=\(published.costProvenance.rawValue)") + print("fixture_window_days=\(selected.historyDays)") + print("fixture_window_tokens=\(selected.last30DaysTokens ?? 0)") + print("fixture_window_cost_usd=\(selected.last30DaysCostUSD ?? 0)") + print("fixture_window_source=\(selected.costProvenance.rawValue)") + } + + @Test(.enabled( + if: ProcessInfo.processInfo.environment["CODEXBAR_LIVE_GROK_CATALOG_PROOF"] == "1", + "Set CODEXBAR_LIVE_GROK_CATALOG_PROOF=1 to scan local Grok sessions.")) + func `writes redacted live Grok window proof`() async throws { + let summary = try await GrokLocalSessionScanner.summarizeOffMainThread( + env: ProcessInfo.processInfo.environment, + lookbackDays: 365) + let published = try #require(summary.toCostUsageTokenSnapshot(historyDays: 365)) + let store = Self.makeStore() + store.publishTokenSnapshot(published, for: .grok) + let remote = UsageSnapshot( + primary: nil, secondary: nil, updatedAt: summary.scannedAt.addingTimeInterval(-60)) + + print("live_full_source=\(published.costProvenance.rawValue)") + for days in [1, 7, 30] { + let selected = try #require(store.tokenSnapshotForLiveProviderConsumer( + fromProviderSnapshot: remote, provider: .grok, historyDays: days)) + #expect(selected.historyDays == days) + #expect(selected.updatedAt == published.updatedAt) + if selected.daily.contains(where: { $0.costUSD != nil }) { + try Self.verifySurfaces(selected, days: days) + } else { + #expect(selected.costProvenance == .unknown) + } + print("live_window_days=\(days)") + print("live_window_tokens=\(selected.last30DaysTokens.map { String($0) } ?? "nil")") + print("live_window_cost_usd=\(selected.last30DaysCostUSD.map { String($0) } ?? "nil")") + print("live_window_source=\(selected.costProvenance.rawValue)") + print("live_window_priced_days=\(selected.daily.count { $0.costUSD != nil })") + } + } + + private static func verifySurfaces(_ snapshot: CostUsageTokenSnapshot, days: Int) throws { + let menu = try #require(UsageMenuCardView.Model.tokenUsageSection( + provider: .grok, + enabled: true, + comparisonPeriodsEnabled: false, + snapshot: snapshot, + error: nil)) + let dashboard = SpendDashboardModel.build( + inputs: [.init(provider: .grok, displayName: "Grok", snapshot: snapshot)], + requestedDays: days, + now: snapshot.updatedAt) + let group = try #require(dashboard.groups.first) + let row = try #require(group.providers.first) + let disclosure = "Grok CLI-recorded spend, list price where unrecorded · not a bill." + #expect(menu.hintLine == disclosure) + #expect(row.costDisclaimer == disclosure) + #expect(row.totalCost == snapshot.last30DaysCostUSD) + #expect(group.provenance == snapshot.costProvenance) + print("window_menu_disclosure=\(menu.hintLine ?? "nil")") + print("window_dashboard_source=\(group.provenance.rawValue)") + } + + private static func makeStore() -> UsageStore { + let settings = testSettingsStore(suiteName: "GrokWindowProvenanceProofTests-\(UUID().uuidString)") + return UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + } +} diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 61cf5fa79a..d6a002c5ac 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -3287,7 +3287,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 632, + line: 628, anchor: "self.tokenFailureGates[.codex]?.reset()", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 2, diff --git a/docs/grok.md b/docs/grok.md index d587fbb4e6..63a4592f3e 100644 --- a/docs/grok.md +++ b/docs/grok.md @@ -137,6 +137,10 @@ The grok.com billing gRPC-web endpoint remains a best-effort fallback. rate. Entries that record no ticks fall back to public xAI list prices and are counted as estimated, so a window reports whether it was recorded, estimated, or a mix of both. Neither figure is a Grok bill. + - Live menu and dashboard publications, including reused in-flight scan results, + derive the cost source from the days retained in the requested window. A shorter + window containing only CLI-recorded turns stays recorded even when older history + includes list-price estimates; an estimate-only window stays estimated. - Reads only a bounded tail of each growing JSONL file, caps individual records and retained parsed turns, bounds session-tree discovery, and reports history as incomplete if a bound is hit. - Uses `signals.json` only as a metadata fallback for sessions with no completed From 79173831ad3d089d8ba3e03b8f3ae07fcc4e60bd Mon Sep 17 00:00:00 2001 From: olddonkey Date: Fri, 4 Sep 2026 13:25:38 -0700 Subject: [PATCH 27/34] Preserve Grok cost sources after dashboard filtering --- .../Grok/UsageStore+GrokLocalSessions.swift | 26 +++---------------- Sources/CodexBar/SpendDashboardModel.swift | 10 ++++++- .../Grok/GrokLocalSessionScanner.swift | 20 ++++++++++++++ .../GrokWindowProvenanceProofTests.swift | 13 ++++++++++ .../ProviderArchitectureGatekeeperTests.swift | 10 ++++++- docs/grok.md | 2 ++ 6 files changed, 57 insertions(+), 24 deletions(-) diff --git a/Sources/CodexBar/Providers/Grok/UsageStore+GrokLocalSessions.swift b/Sources/CodexBar/Providers/Grok/UsageStore+GrokLocalSessions.swift index ef80f9bdcb..6af05f098b 100644 --- a/Sources/CodexBar/Providers/Grok/UsageStore+GrokLocalSessions.swift +++ b/Sources/CodexBar/Providers/Grok/UsageStore+GrokLocalSessions.swift @@ -37,7 +37,7 @@ extension UsageStore { historyCoverageIsEstablished: published.historyCoverageIsEstablished && published.historyDays >= days, historyLabel: published.historyLabel, meteredCostUSD: published.meteredCostUSD, - costProvenance: Self.grokWindowProvenance(published: published.costProvenance, daily: daily), + costProvenance: GrokLocalSessionSummary.costProvenance(for: daily, fallback: published.costProvenance), credentialScopeFingerprint: published.credentialScopeFingerprint, daily: daily, projects: published.projects, @@ -71,7 +71,9 @@ extension UsageStore { historyCoverageIsEstablished: narrowed.historyCoverageIsEstablished, historyLabel: narrowed.historyLabel, meteredCostUSD: narrowed.meteredCostUSD, - costProvenance: Self.grokWindowProvenance(published: published.costProvenance, daily: narrowed.daily), + costProvenance: GrokLocalSessionSummary.costProvenance( + for: narrowed.daily, + fallback: published.costProvenance), credentialScopeFingerprint: narrowed.credentialScopeFingerprint, daily: narrowed.daily, projects: narrowed.projects, @@ -80,26 +82,6 @@ extension UsageStore { updatedAt: narrowed.updatedAt) } - /// The disclosure has to describe the days this window kept. Grok's scanner counts a CLI-recorded turn - /// as priced and a public-card fallback as estimated, so the retained rows say which sources survived; - /// a window that kept no priced rows claims nothing. - private static func grokWindowProvenance( - published: CostProvenance, - daily: [CostUsageDailyReport.Entry]) -> CostProvenance - { - var counts = CostUsageCoverageCounts() - for entry in daily { - counts.merge(entry.coverageCounts) - } - guard daily.contains(where: { $0.costUSD != nil }) else { return .unknown } - switch (counts.priced > 0, counts.estimated > 0) { - case (true, true): return .mixed - case (true, false): return .vendorMetered - case (false, true): return .listPriceEstimate - case (false, false): return published - } - } - private static func grokLocalDayKey(for date: Date, calendar: Calendar) -> String? { let parts = calendar.dateComponents([.year, .month, .day], from: date) guard let year = parts.year, let month = parts.month, let day = parts.day else { return nil } diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index f8256037a1..17f368209f 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -495,7 +495,15 @@ struct SpendDashboardModel: Equatable, Sendable { metered = (metered ?? 0) + meteredCost * summary.costMultiplier } if summary.totalCost != nil { - switch summary.input.snapshot.costProvenance { + // Provider-specific by design: Grok owns the recorded-versus-estimated meaning of its row coverage. + let provenance = if summary.input.provider == .grok { + GrokLocalSessionSummary.costProvenance( + for: summary.entries.map(\.entry), + fallback: summary.input.snapshot.costProvenance) + } else { + summary.input.snapshot.costProvenance + } + switch provenance { case .vendorMetered: sawVendorMeteredProvenance = true case .listPriceEstimate: diff --git a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift index b868480648..9a38fe5245 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift @@ -123,6 +123,26 @@ public struct GrokLocalSessionSummary: Sendable { daily: entries, updatedAt: self.scannedAt) } + + /// Grok counts recorded turns as priced and public-card fallbacks as estimated. Only a mixed + /// snapshot needs these counts to decide which sources survived a window or selected-day filter. + public static func costProvenance( + for daily: [CostUsageDailyReport.Entry], + fallback: CostProvenance) -> CostProvenance + { + guard daily.contains(where: { $0.costUSD != nil }) else { return .unknown } + guard fallback == .mixed else { return fallback } + var counts = CostUsageCoverageCounts() + for entry in daily { + counts.merge(entry.coverageCounts) + } + switch (counts.priced > 0, counts.estimated > 0) { + case (true, true): return .mixed + case (true, false): return .vendorMetered + case (false, true): return .listPriceEstimate + case (false, false): return fallback + } + } } struct GrokLocalSessionParseCacheMetrics: Sendable, Equatable { diff --git a/Tests/CodexBarTests/GrokWindowProvenanceProofTests.swift b/Tests/CodexBarTests/GrokWindowProvenanceProofTests.swift index 714a64d4b6..fa3518fc97 100644 --- a/Tests/CodexBarTests/GrokWindowProvenanceProofTests.swift +++ b/Tests/CodexBarTests/GrokWindowProvenanceProofTests.swift @@ -42,6 +42,19 @@ struct GrokWindowProvenanceProofTests: GrokLocalSessionScannerTestSupport { #expect(abs((selected.last30DaysCostUSD ?? 0) - expectedCost) < 1e-12) #expect(selected.costProvenance == expectedSource) try Self.verifySurfaces(selected, days: 30) + // The dashboard retains the full scan and applies its own range/day selection after capture. + // Exercise that path too, rather than giving it an already narrowed snapshot. + for (days, selectedDay) in [(30, Date?.none), (365, Optional(recent))] { + let dashboard = SpendDashboardModel.build( + inputs: [.init(provider: .grok, displayName: "Grok", snapshot: published)], + requestedDays: days, + now: now, + selectedDay: selectedDay) + let group = try #require(dashboard.groups.first) + #expect(group.provenance == expectedSource) + print("fixture_dashboard_filter=\(selectedDay == nil ? "30-day-window" : "selected-day")") + print("fixture_dashboard_source=\(group.provenance.rawValue)") + } print("fixture_full_source=\(published.costProvenance.rawValue)") print("fixture_window_days=\(selected.historyDays)") print("fixture_window_tokens=\(selected.last30DaysTokens ?? 0)") diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index d6a002c5ac..e0d2c51ff0 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -2487,7 +2487,15 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardModel.swift", - line: 1099, + line: 499, + anchor: "let provenance = if summary.input.provider == .grok {", + expectedProviderIDs: ["grok"], + expectedReferenceCount: 1, + expectedReferenceFingerprint: ["grok@0"], + reason: "Grok owns how retained row coverage distinguishes recorded spend from list-price estimates."), + AllowedProviderConstruct( + path: "Sources/CodexBar/SpendDashboardModel.swift", + line: 1107, anchor: "guard provider == .mistral || provider == .openrouter || provider == .xai else { return displayCalendar }", expectedProviderIDs: ["mistral", "openrouter", "xai"], expectedReferenceCount: 3, diff --git a/docs/grok.md b/docs/grok.md index 63a4592f3e..31f7625b6b 100644 --- a/docs/grok.md +++ b/docs/grok.md @@ -141,6 +141,8 @@ The grok.com billing gRPC-web endpoint remains a best-effort fallback. derive the cost source from the days retained in the requested window. A shorter window containing only CLI-recorded turns stays recorded even when older history includes list-price estimates; an estimate-only window stays estimated. + Usage & Spend applies the same source calculation when filtering its retained + 365-day input by history range or by a selected day. - Reads only a bounded tail of each growing JSONL file, caps individual records and retained parsed turns, bounds session-tree discovery, and reports history as incomplete if a bound is hit. - Uses `signals.json` only as a metadata fallback for sessions with no completed From 3c33da05aecf958a1853af0923caede26601cbce Mon Sep 17 00:00:00 2001 From: olddonkey Date: Fri, 4 Sep 2026 13:52:21 -0700 Subject: [PATCH 28/34] Honor Grok turn-level recorded costs --- .../Grok/GrokLocalSessionScanner.swift | 54 ++++++--- .../GrokRecordedTurnCostTests.swift | 106 ++++++++++++++++++ .../GrokWindowProvenanceProofTests.swift | 13 ++- docs/grok.md | 5 + 4 files changed, 159 insertions(+), 19 deletions(-) create mode 100644 Tests/CodexBarTests/GrokRecordedTurnCostTests.swift diff --git a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift index 9a38fe5245..4c25110d5e 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift @@ -395,6 +395,7 @@ public enum GrokLocalSessionScanner { var requestCount = 0 var costUSD = 0.0 var hasPricedCost = false + var hasUnattributedCost = false } private struct MutableDailyBucket { @@ -968,7 +969,11 @@ public enum GrokLocalSessionScanner { let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed?.isEmpty == false ? trimmed : nil } +} + +// MARK: - Daily aggregation and pricing +extension GrokLocalSessionScanner { private static func aggregate( turn: GrokParsedTurn, sessionPath: String, @@ -986,16 +991,18 @@ public enum GrokLocalSessionScanner { bucket.totalTokens += turn.usage.totalTokens bucket.sessionIDs.insert(sessionPath) + // The outer tick is the authoritative turn total, including when model usage is populated. + let recordedTurnCost = turn.usage.costUsdTicks.map { Double($0) / Self.costUsdTicksPerUSD } + let modelCostsMatchTurn = self.recordedModelCostsMatchTurn(turn) + if let recordedTurnCost { + bucket.costUSD += recordedTurnCost + bucket.hasPricedCost = true + aggregation.sawRecordedCost = true + } if turn.modelUsage.isEmpty { let requests = self.requestCount(for: turn.usage) bucket.requestCount += requests - // A turn with no per-model attribution still carries its own recorded spend; only the - // list-price path needs a SKU, so an unattributed turn is unpriced without recorded ticks. - if let recorded = turn.usage.costUsdTicks { - bucket.costUSD += Double(recorded) / Self.costUsdTicksPerUSD - bucket.hasPricedCost = true - aggregation.sawRecordedCost = true - } else { + if recordedTurnCost == nil { bucket.unpricedRequestCount += requests } } @@ -1013,9 +1020,16 @@ public enum GrokLocalSessionScanner { breakdown.totalTokens += usage.totalTokens breakdown.requestCount += requests - // The CLI's recorded spend already carries the price tier and any promotional rate, so it wins - // over a reconstruction whenever the record has it. The public card stays the fallback. - if let recorded = usage.costUsdTicks { + if recordedTurnCost != nil { + // Keep model dollars only when the complete breakdown agrees with the paid turn total. + // The outer total was already counted, so nested ticks never add to it again. + if modelCostsMatchTurn, let recorded = usage.costUsdTicks { + breakdown.costUSD += Double(recorded) / Self.costUsdTicksPerUSD + breakdown.hasPricedCost = true + } else { + breakdown.hasUnattributedCost = true + } + } else if let recorded = usage.costUsdTicks { let cost = Double(recorded) / Self.costUsdTicksPerUSD breakdown.costUSD += cost breakdown.hasPricedCost = true @@ -1042,6 +1056,18 @@ public enum GrokLocalSessionScanner { aggregation.daily[day] = bucket } + private static func recordedModelCostsMatchTurn(_ turn: GrokParsedTurn) -> Bool { + guard let total = turn.usage.costUsdTicks, !turn.modelUsage.isEmpty else { return false } + var modelTotal = 0 + for usage in turn.modelUsage.values { + guard let recorded = usage.costUsdTicks else { return false } + let (sum, overflow) = modelTotal.addingReportingOverflow(recorded) + guard !overflow else { return false } + modelTotal = sum + } + return modelTotal == total + } + private static func costUSD( sku: String, usage: GrokParsedTokenUsage, @@ -1070,10 +1096,8 @@ public enum GrokLocalSessionScanner { outputTokens: usage.outputTokens) } - // Even splitting is intentionally an approximation: context normally grows within a turn, - // so mean per-call inputs under-tier later calls. In a measured 27-turn sample this was about - // 4% below the vendor tick proxy overall and 26% low on one 28-call turn. Vendor ticks still - // do not drive displayed cost; the split is retained because aggregate tiering overstates it. + // Without recorded turn or model spend, even splitting approximates public list prices. + // Context can grow within a turn, so mean per-call inputs can under-tier later calls. return self.syntheticCallGroups( usage: usage, callCount: callCount, @@ -1224,7 +1248,7 @@ public enum GrokLocalSessionScanner { guard let value = bucket.modelBreakdowns[model] else { return nil } return CostUsageDailyReport.ModelBreakdown( modelName: model, - costUSD: value.hasPricedCost ? value.costUSD : nil, + costUSD: value.hasPricedCost && !value.hasUnattributedCost ? value.costUSD : nil, totalTokens: value.totalTokens, requestCount: value.requestCount, inputTokens: value.inputTokens, diff --git a/Tests/CodexBarTests/GrokRecordedTurnCostTests.swift b/Tests/CodexBarTests/GrokRecordedTurnCostTests.swift new file mode 100644 index 0000000000..87ea3acd9b --- /dev/null +++ b/Tests/CodexBarTests/GrokRecordedTurnCostTests.swift @@ -0,0 +1,106 @@ +import Foundation +import Testing +@testable import CodexBarCore + +extension GrokCostUsagePricingTests { + @Test(arguments: [false, true]) + func `outer recorded spend is counted once with populated model usage`(hasNestedCosts: Bool) throws { + let summary = try self.recordedModelsSummary( + outerTicks: 10_000_000_000, + firstModelTicks: hasNestedCosts ? 4_000_000_000 : nil, + secondModelTicks: hasNestedCosts ? 6_000_000_000 : nil) + let day = try #require(summary.daily.first) + #expect(day.costUSD == 1) + #expect(day.totalTokens == 2000) + #expect(day.requestCount == 2) + #expect(day.estimatedRequestCount == 0) + #expect(day.unpricedRequestCount == 0) + #expect(summary.costProvenance == .vendorMetered) + if hasNestedCosts { + #expect(day.modelBreakdowns.compactMap(\.costUSD).reduce(0, +) == 1) + } else { + #expect(day.modelBreakdowns.allSatisfy { $0.costUSD == nil }) + } + } + + @Test(arguments: [false, true]) + func `outer recorded spend wins over inconsistent or partial nested costs`(partial: Bool) throws { + let summary = try self.recordedModelsSummary( + outerTicks: 10_000_000_000, + firstModelTicks: 4_000_000_000, + secondModelTicks: partial ? nil : 80_000_000_000) + let day = try #require(summary.daily.first) + #expect(day.costUSD == 1) + #expect(day.estimatedRequestCount == 0) + #expect(day.unpricedRequestCount == 0) + #expect(summary.costProvenance == .vendorMetered) + #expect(day.modelBreakdowns.allSatisfy { $0.costUSD == nil }) + #expect(day.modelBreakdowns.compactMap(\.totalTokens).reduce(0, +) == 2000) + } + + @Test(arguments: [false, true]) + func `missing or zero outer spend retains nested recorded costs`(zeroOuter: Bool) throws { + let summary = try self.recordedModelsSummary( + outerTicks: zeroOuter ? 0 : nil, + firstModelTicks: 4_000_000_000, + secondModelTicks: 6_000_000_000) + let day = try #require(summary.daily.first) + #expect(day.costUSD == 1) + #expect(day.modelBreakdowns.compactMap(\.costUSD).reduce(0, +) == 1) + #expect(day.estimatedRequestCount == 0) + #expect(summary.costProvenance == .vendorMetered) + } + + @Test + func `unattributed outer spend keeps the combined model cost unknown`() throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let now = try self.localDate(day: 20, hour: 12) + let outerOnly = self.usage( + input: 1000, + output: 0, + modelCalls: 1, + costUsdTicks: 10_000_000_000, + modelUsage: ["grok-4.6-build": self.modelUsage(input: 1000, output: 0, modelCalls: 1)]) + try self.writeUpdates( + [ + self.turn(timestamp: now, usage: outerOnly), + self.turn(timestamp: now.addingTimeInterval(1), usage: self.singleModelUsage( + input: 1000, output: 0, costUsdTicks: 4_000_000_000)), + ], + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: now.addingTimeInterval(60)) + let summary = try self.summarize(fixture: fixture, now: now.addingTimeInterval(120)) + let day = try #require(summary.daily.first) + #expect(abs((day.costUSD ?? 0) - 1.4) < 1e-12) + #expect(day.modelBreakdowns.first?.costUSD == nil) + #expect(day.totalTokens == 2000) + #expect(summary.costProvenance == .vendorMetered) + } + + private func recordedModelsSummary( + outerTicks: Int?, + firstModelTicks: Int?, + secondModelTicks: Int?) throws -> GrokLocalSessionSummary + { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let now = try self.localDate(day: 20, hour: 12) + let usage = self.usage( + input: 2000, + output: 0, + modelCalls: 2, + costUsdTicks: outerTicks, + modelUsage: [ + "grok-4.6-build": self.modelUsage( + input: 1000, output: 0, modelCalls: 1, costUsdTicks: firstModelTicks), + "grok-test-model": self.modelUsage( + input: 1000, output: 0, modelCalls: 1, costUsdTicks: secondModelTicks), + ]) + try self.writeUpdates( + [self.turn(timestamp: now, usage: usage)], + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: now.addingTimeInterval(60)) + return try self.summarize(fixture: fixture, now: now.addingTimeInterval(120)) + } +} diff --git a/Tests/CodexBarTests/GrokWindowProvenanceProofTests.swift b/Tests/CodexBarTests/GrokWindowProvenanceProofTests.swift index fa3518fc97..19a712ad92 100644 --- a/Tests/CodexBarTests/GrokWindowProvenanceProofTests.swift +++ b/Tests/CodexBarTests/GrokWindowProvenanceProofTests.swift @@ -12,12 +12,17 @@ struct GrokWindowProvenanceProofTests: GrokLocalSessionScannerTestSupport { let now = Date() let recent = try #require(Calendar.current.date(byAdding: .day, value: -2, to: now)) let older = try #require(Calendar.current.date(byAdding: .day, value: -120, to: now)) + let recordedUsage = self.usage( + input: 1000, + output: 0, + modelCalls: 1, + costUsdTicks: 10_000_000_000, + modelUsage: ["grok-4.6-build": self.modelUsage(input: 1000, output: 0, modelCalls: 1)]) + let estimatedUsage = self.singleModelUsage(input: 1000, output: 0) try self.writeUpdates( [ - self.turn(timestamp: older, usage: self.singleModelUsage( - input: 1000, output: 0, costUsdTicks: estimatedRecently ? 10_000_000_000 : nil)), - self.turn(timestamp: recent, usage: self.singleModelUsage( - input: 1000, output: 0, costUsdTicks: estimatedRecently ? nil : 10_000_000_000)), + self.turn(timestamp: older, usage: estimatedRecently ? recordedUsage : estimatedUsage), + self.turn(timestamp: recent, usage: estimatedRecently ? estimatedUsage : recordedUsage), ], to: fixture.session.appendingPathComponent("updates.jsonl"), modificationDate: now) diff --git a/docs/grok.md b/docs/grok.md index 31f7625b6b..65edf7ec87 100644 --- a/docs/grok.md +++ b/docs/grok.md @@ -137,6 +137,11 @@ The grok.com billing gRPC-web endpoint remains a best-effort fallback. rate. Entries that record no ticks fall back to public xAI list prices and are counted as estimated, so a window reports whether it was recorded, estimated, or a mix of both. Neither figure is a Grok bill. + - A positive outer `usage.costUsdTicks` is the authoritative turn total and is + counted once, even when `modelUsage` is populated. Nested recorded costs are used + for model dollars only when every model has ticks and their sum matches that total; + otherwise model tokens remain available while model dollars stay unknown. Without + a positive outer total, each model uses its recorded ticks or the list-price fallback. - Live menu and dashboard publications, including reused in-flight scan results, derive the cost source from the days retained in the requested window. A shorter window containing only CLI-recorded turns stays recorded even when older history From b9486a697a22d622f3a4a1a4d6e8b16ae5f98480 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Sat, 5 Sep 2026 00:40:09 -0700 Subject: [PATCH 29/34] Refresh Grok integration after main changes --- .../ProviderArchitectureGatekeeperTests.swift | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index e0d2c51ff0..23aca44db6 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1344,19 +1344,19 @@ struct ProviderArchitectureGatekeeperTests { reason: "Claude widget quota ownership uses the selected Claude account's isolated snapshot key."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore.swift", - line: 1079, + line: 1067, anchor: "provider: .deepseek,", expectedProviderIDs: ["deepseek"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore.swift", - line: 1181, + line: 1169, anchor: "let sourceMode = self.sourceMode(for: .claude)", expectedProviderIDs: ["claude"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore.swift", - line: 1185, + line: 1173, anchor: "provider: .claude,", expectedProviderIDs: ["claude"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), @@ -3416,7 +3416,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 619, + line: 623, anchor: "self.metadata(for: .codex).browserCookieOrder ?? Browser.defaultImportOrder", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3424,7 +3424,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 671, + line: 675, anchor: "self.providerSpecs[provider]?.style ?? .codex", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3432,7 +3432,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 704, + line: 708, anchor: "guard provider != .codex else { return true }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3440,7 +3440,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1053, + line: 1041, anchor: "let claudeDebugConfiguration: ClaudeDebugLogConfiguration? = if provider == .claude {", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3448,7 +3448,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1076, + line: 1064, anchor: "let deepSeekHasTokenAccount = self.settings.selectedTokenAccount(for: .deepseek) != nil", expectedProviderIDs: ["deepseek"], expectedReferenceCount: 1, @@ -3456,7 +3456,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1133, + line: 1121, anchor: "case .amp:", expectedProviderIDs: ["amp", "deepseek", "notion", "ollama", "warp"], expectedReferenceCount: 7, @@ -3472,7 +3472,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1188, + line: 1176, anchor: "let claudeSettings = snapshot.claude ?? ProviderSettingsSnapshot.ClaudeProviderSettings(", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3827,7 +3827,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift", - line: 501, + line: 502, anchor: "providerIDs.append(\"opencode\")", expectedProviderIDs: ["opencode", "xai"], expectedReferenceCount: 2, @@ -3835,7 +3835,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift", - line: 534, + line: 545, anchor: "if self.codex[trimmed] != nil {", expectedProviderIDs: ["codex"], expectedReferenceCount: 2, @@ -3843,7 +3843,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift", - line: 577, + line: 588, anchor: "if self.claude[base] != nil {", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3851,7 +3851,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift", - line: 610, + line: 621, anchor: "let bundled = lookup.pricing.providerID == self.codexModelsDevProviderID ? self.codex[key] : nil", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3859,7 +3859,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift", - line: 644, + line: 655, anchor: "guard let pricing = self.codex[key] else { return nil }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3867,7 +3867,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift", - line: 799, + line: 810, anchor: "guard let pricing = self.claude[key] else { return nil }", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, From 9aba5e0d62b0168c611e14515a9b22e16442a6db Mon Sep 17 00:00:00 2001 From: olddonkey Date: Sat, 5 Sep 2026 01:19:19 -0700 Subject: [PATCH 30/34] Include recorded OpenCodex Grok OAuth usage in spend --- CHANGELOG.md | 1 + .../SpendDashboardSource+OpenCodex.swift | 3 +- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsagePricing.swift | 8 +- .../OpenCodexRouteDispatcher.swift | 15 +- .../OpenCodexUsageAggregator.swift | 14 +- .../OpenCodexUsage/OpenCodexUsageFanOut.swift | 32 ++- .../OpenCodexUsage/OpenCodexUsageModels.swift | 43 +++- .../OpenCodexUsage/OpenCodexUsageParser.swift | 44 ++-- .../OpenCodexUsage/OpenCodexUsageStore.swift | 22 +- .../GrokOpenCodexUsageTests.swift | 201 ++++++++++++++++++ .../ProviderArchitectureGatekeeperTests.swift | 10 +- docs/grok.md | 19 +- 13 files changed, 366 insertions(+), 48 deletions(-) create mode 100644 Tests/CodexBarTests/GrokOpenCodexUsageTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e94443b48..8762b982b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ ### Usage & Spend - Grok: count completed-turn usage from bounded local CLI session-log scans instead of context-window occupancy, and price it from the spend the CLI recorded, falling back to clearly labeled public xAI list prices where it recorded none; OpenCodex xAI history remains token-only without request-time credential provenance (#3135, #3345). Thanks @olddonkey and @initH271! +- Usage & Spend: include reported OpenCodex Grok OAuth attempts when log import is enabled and the log records request-time credential provenance. Keep API-key and historic traffic excluded, and label OpenCodex dollars as list-price estimates (#3135). Thanks @olddonkey! ## 0.56.5 — 2026-09-04 diff --git a/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift b/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift index b4567bb4e7..84150b4a48 100644 --- a/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift +++ b/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift @@ -111,7 +111,8 @@ extension SpendDashboardSource { supplement, now: request.now, historyDays: self.scanDays, - calendar: request.configuration.bucketCalendar), + calendar: request.configuration.bucketCalendar, + provider: input.provider), tokenActivityCache: input.tokenActivityCache, sourceKind: input.sourceKind) } diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 9a7e62b741..469e821ab7 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 = "38c892ecd2ee2447" + static let value = "0bd6588c70196700" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 403843bef3..6bc358aa3b 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -507,12 +507,10 @@ enum CostUsagePricing { // `grok-build-0.1` does not end in `-build` and must remain an exact catalog identity. if routeID == "xai", modelID.hasPrefix("grok-"), - modelID.hasSuffix("-build") + modelID.hasSuffix("-build"), + modelID.count > "grok-".count + "-build".count { - let normalized = String(modelID.dropLast("-build".count)) - if normalized.count > "grok-".count { - targets.append((routeID, normalized)) - } + targets.append((routeID, String(modelID.dropLast("-build".count)))) } if routeID == self.codexModelsDevProviderID { let normalized = self.normalizeCodexModel(modelID) diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexRouteDispatcher.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexRouteDispatcher.swift index fbc483cae0..3de8f3eacd 100644 --- a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexRouteDispatcher.swift +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexRouteDispatcher.swift @@ -7,6 +7,14 @@ public enum OpenCodexRouteTarget: Equatable, Sendable { } public enum OpenCodexRouteDispatcher { + static func route(entry: OpenCodexUsageEntry) -> OpenCodexRouteTarget { + // Provider-specific by design: only derived, request-time OAuth attempts enter Grok's subscription. + if entry.provider == "xai", entry.credentialSource == .grokOAuth { + return .subscription(.grok) + } + return self.route(provider: entry.provider, modelName: entry.model) + } + public static func route(provider: String) -> OpenCodexRouteTarget { // Provider-specific by design: OpenCodex provider prefixes map onto subscription rows or token-only spend. let providerID = provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() @@ -14,9 +22,8 @@ public enum OpenCodexRouteDispatcher { case "openai": return .subscription(.codex) case "xai": - // usage.jsonl does not retain the credential mode that produced a request. The current config cannot - // safely reclassify historical API-key and OAuth traffic, so xAI stays out of the Grok subscription row - // until the log carries record-time provenance. + // Legacy rows and API-key traffic have no subscription attribution. Only the entry-aware + // overload accepts Grok OAuth provenance derived from a physical attempt. return .tokenOnly case "opencode-go": return .subscription(.opencodego) @@ -33,6 +40,7 @@ public enum OpenCodexRouteDispatcher { public static func route(modelName: String) -> OpenCodexRouteTarget { let trimmed = modelName.trimmingCharacters(in: .whitespacesAndNewlines) + // Provider-specific by design: OpenCodex bare model selectors default to the Codex subscription. guard let slash = trimmed.firstIndex(of: "/") else { return .subscription(.codex) } @@ -42,6 +50,7 @@ public enum OpenCodexRouteDispatcher { } public static func countsTowardCodexSubscription(modelName: String) -> Bool { + // Provider-specific by design: this public predicate filters explicitly for the Codex subscription. if case .subscription(.codex) = self.route(modelName: modelName) { return true } diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift index f2e392b2a0..285b15be9c 100644 --- a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift @@ -226,20 +226,21 @@ enum OpenCodexUsageAggregator { day.tokens += tokens day.sawTokens = true } - day.priced += entry.usageStatus == .reported ? 1 : 0 - day.estimated += entry.usageStatus == .estimated ? 1 : 0 + let usesGrokEstimate = entry.credentialSource == .grokOAuth + day.priced += entry.usageStatus == .reported && !usesGrokEstimate ? 1 : 0 + day.estimated += entry.usageStatus == .estimated || usesGrokEstimate ? 1 : 0 day.unmetered += entry.usageStatus == .unsupported ? 1 : 0 day.unpriced += entry.usageStatus == .unreported ? 1 : 0 if let cost { day.cost += cost day.sawCost = true - } else if entry.usageStatus == .reported { + } else if entry.usageStatus == .reported && !usesGrokEstimate { day.unpriced += 1 if day.priced > 0 { day.priced -= 1 } - } else if entry.usageStatus == .estimated { + } else if entry.usageStatus == .estimated || usesGrokEstimate { day.unpriced += 1 if day.estimated > 0 { day.estimated -= 1 @@ -349,7 +350,7 @@ enum OpenCodexUsageAggregator { // Provider-specific by design: token-only routes lack the request-time credential provenance needed to // decide whether their traffic belongs to a subscription or an API bill. Keep their standalone OpenCodex // rows token-only too instead of attaching a dollar amount that the subscription fan-out intentionally drops. - guard OpenCodexRouteDispatcher.route(provider: entry.provider, modelName: entry.model) != .tokenOnly else { + guard OpenCodexRouteDispatcher.route(entry: entry) != .tokenOnly else { return nil } guard entry.usageStatus == .reported || entry.usageStatus == .estimated else { return nil } @@ -360,6 +361,9 @@ enum OpenCodexUsageAggregator { || usage?.cacheReadTokens != nil || usage?.cacheCreationInputTokens != nil guard hasTokenData else { return nil } + if entry.credentialSource == .grokOAuth, usage?.inputTokens == nil || usage?.outputTokens == nil { + return nil + } let input = usage?.inputTokens ?? 0 let output = usage?.outputTokens ?? 0 let cacheRead = usage?.cacheReadTokens ?? 0 diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageFanOut.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageFanOut.swift index a060ff8df6..05de85c73d 100644 --- a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageFanOut.swift +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageFanOut.swift @@ -9,7 +9,14 @@ public enum OpenCodexUsageFanOut { customPricing: CostUsageCustomPricing = .empty) -> [UsageProvider: CostUsageTokenSnapshot] { var grouped: [UsageProvider: [OpenCodexUsageEntry]] = [:] - for entry in entries { + let latest = Dictionary(entries.map { ($0.requestID, $0) }, uniquingKeysWith: { _, latest in latest }) + for entry in latest.values { + let grokAttempts = self.grokOAuthEntries(from: entry) + if !grokAttempts.isEmpty { + // Provider-specific by design: each OAuth attempt contributes only its own reported Grok usage. + grouped[.grok, default: []].append(contentsOf: grokAttempts) + } + if entry.provider == "xai" || entry.provider == "combo" { continue } guard case let .subscription(provider) = OpenCodexRouteDispatcher.route( provider: entry.provider, modelName: entry.model) @@ -36,12 +43,29 @@ public enum OpenCodexUsageFanOut { } } + static func grokOAuthEntries(from entry: OpenCodexUsageEntry) -> [OpenCodexUsageEntry] { + // Provider-specific by design: only physical xAI attempts from xAI or combo parents can prove Grok usage. + guard entry.provider == "xai" || entry.provider == "combo" else { return [] } + // Duplicate ordinals are ambiguous. Drop every copy rather than selecting whichever came first. + let ordinals = Dictionary(grouping: entry.attempts, by: \.ordinal) + return entry.attempts.compactMap { attempt in + guard ordinals[attempt.ordinal]?.count == 1, + attempt.provider == "xai", attempt.credentialSource == .grokOAuth, + attempt.sendCount > 0, !attempt.locallyAnswered, attempt.usageStatus == .reported, + attempt.usage != nil, + !attempt.model.contains("/") || attempt.model.hasPrefix("xai/") + else { return nil } + return OpenCodexUsageEntry(parent: entry, attempt: attempt) + } + } + public static func mergeSnapshots( _ base: CostUsageTokenSnapshot, _ supplement: CostUsageTokenSnapshot, now: Date, historyDays: Int, - calendar: Calendar) -> CostUsageTokenSnapshot + calendar: Calendar, + provider: UsageProvider? = nil) -> CostUsageTokenSnapshot { let mergedReport = CostUsageDailyReport.merged([ CostUsageDailyReport(data: base.daily, summary: nil), @@ -64,6 +88,10 @@ public enum OpenCodexUsageFanOut { calendar: calendar, historyCoverageIsEstablished: base.historyCoverageIsEstablished && supplement.historyCoverageIsEstablished, + // Provider-specific by design: Grok recorded and OpenCodex estimated rows retain distinct coverage. + costProvenance: provider == .grok + ? GrokLocalSessionSummary.costProvenance(for: mergedReport.data, fallback: .mixed) + : .unknown, projects: projects, sessions: sessions, updatedAt: max(base.updatedAt, supplement.updatedAt)) diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageModels.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageModels.swift index a432bcd80b..5bbb504290 100644 --- a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageModels.swift +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageModels.swift @@ -7,7 +7,7 @@ public enum OpenCodexUsageStatus: String, Sendable, Equatable, Codable { case unsupported } -public struct OpenCodexTokenUsage: Sendable, Equatable { +public struct OpenCodexTokenUsage: Sendable, Equatable, Codable { public var inputTokens: Int? public var outputTokens: Int? public var cachedInputTokens: Int? @@ -58,6 +58,23 @@ public struct OpenCodexTokenUsage: Sendable, Equatable { } } +public enum OpenCodexUsageCredentialSource: String, Sendable, Equatable, Codable { + case grokOAuth = "grok-oauth" + case xaiAPIKey = "xai-api-key" +} + +public struct OpenCodexUsageAttempt: Sendable, Equatable, Codable { + public let ordinal: Int + public let provider: String + public let model: String + public let credentialSource: OpenCodexUsageCredentialSource? + public let usageStatus: OpenCodexUsageStatus + public let sendCount: Int + public let locallyAnswered: Bool + public let usage: OpenCodexTokenUsage? + public let totalTokens: Int? +} + public struct OpenCodexUsageEntry: Sendable, Equatable { public let requestID: String public let timestamp: Date @@ -69,6 +86,9 @@ public struct OpenCodexUsageEntry: Sendable, Equatable { public let conversationID: String? public let usage: OpenCodexTokenUsage? public let totalTokens: Int? + public let attempts: [OpenCodexUsageAttempt] + /// Set only on a derived physical-attempt entry, never from top-level JSON metadata. + let credentialSource: OpenCodexUsageCredentialSource? public init( requestID: String, @@ -80,8 +100,11 @@ public struct OpenCodexUsageEntry: Sendable, Equatable { surface: String? = nil, conversationID: String? = nil, usage: OpenCodexTokenUsage? = nil, - totalTokens: Int? = nil) + totalTokens: Int? = nil, + attempts: [OpenCodexUsageAttempt] = []) { + self.credentialSource = nil + self.attempts = attempts self.requestID = requestID self.timestamp = timestamp self.provider = provider @@ -94,6 +117,22 @@ public struct OpenCodexUsageEntry: Sendable, Equatable { self.totalTokens = totalTokens } + init(parent: OpenCodexUsageEntry, attempt: OpenCodexUsageAttempt) { + // A length prefix avoids collisions between request IDs containing separators. + self.requestID = "ocx-attempt:\(parent.requestID.utf8.count):\(parent.requestID):\(attempt.ordinal)" + self.timestamp = parent.timestamp + self.provider = attempt.provider + self.model = attempt.model + self.usageStatus = attempt.usageStatus + self.accountLogLabel = nil + self.surface = parent.surface + self.conversationID = parent.conversationID + self.usage = attempt.usage + self.totalTokens = attempt.totalTokens + self.attempts = [] + self.credentialSource = attempt.credentialSource + } + public var resolvedTotalTokens: Int? { self.totalTokens ?? self.usage?.resolvedTotalTokens } diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageParser.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageParser.swift index 607c8605ea..3736ee5881 100644 --- a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageParser.swift +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageParser.swift @@ -1,3 +1,4 @@ +import CoreFoundation #if canImport(Darwin) import Darwin #elseif canImport(Glibc) @@ -270,7 +271,30 @@ public enum OpenCodexUsageParser { surface: self.nonEmptyString(object["surface"]), conversationID: self.nonEmptyString(object["conversationId"]), usage: usage, - totalTokens: self.nonnegativeInt(object["totalTokens"])) + totalTokens: self.nonnegativeInt(object["totalTokens"]), + attempts: self.attempts(object["attempts"])) + } + + private static func attempts(_ value: Any?) -> [OpenCodexUsageAttempt] { + guard let rows = value as? [[String: Any]] else { return [] } + return rows.compactMap { row in + guard let ordinal = self.nonnegativeInt(row["ordinal"]), ordinal > 0, + let provider = self.nonEmptyString(row["provider"]), + let model = self.nonEmptyString(row["model"]), + let sendCount = self.nonnegativeInt(row["sendCount"]) + else { return nil } + return OpenCodexUsageAttempt( + ordinal: ordinal, + provider: provider, + model: model, + credentialSource: (row["credentialSource"] as? String) + .flatMap(OpenCodexUsageCredentialSource.init(rawValue:)), + usageStatus: self.usageStatus(row["usageStatus"]), + sendCount: sendCount, + locallyAnswered: row["locallyAnswered"] as? Bool ?? false, + usage: self.usage(row["usage"]), + totalTokens: self.nonnegativeInt(row["totalTokens"])) + } } private static func usageStatus(_ value: Any?) -> OpenCodexUsageStatus { @@ -339,18 +363,12 @@ public enum OpenCodexUsageParser { } private static func nonnegativeInt(_ value: Any?) -> Int? { - guard let value else { return nil } - if let number = value as? Int { - return number >= 0 ? number : nil - } - if let number = value as? Double, number.isFinite, number >= 0, number <= Double(Int.max) { - return Int(number) - } - if let number = value as? NSNumber { - let intValue = number.intValue - return intValue >= 0 ? intValue : nil - } - return nil + guard let number = value as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID() + else { return nil } + if let integer = value as? Int { return integer >= 0 ? integer : nil } + guard let integer = Int(exactly: number.doubleValue), integer >= 0 else { return nil } + return integer } private static func prefixDigest( diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageStore.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageStore.swift index 9fae10b9b2..a697458d86 100644 --- a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageStore.swift +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageStore.swift @@ -22,7 +22,7 @@ public struct OpenCodexUsageStore: Sendable { /// Schema v2 lives in a versioned filename so a v1 build keeps using `opencodex-usage.sqlite`. /// Leave that older file alone; do not delete it. public static let databaseFilename = "opencodex-usage-v2.sqlite" - private static let schemaVersion = 2 + private static let schemaVersion = 3 private static let cursorMetaKey = "parseCursor" private static let prefixDigestByteLimit = 64 * 1024 @@ -254,7 +254,7 @@ public struct OpenCodexUsageStore: Sendable { let sql = """ SELECT request_id, timestamp, provider, model, usage_status, account_label, surface, conversation_id, \ input_tokens, output_tokens, cached_input_tokens, cache_read_input_tokens, \ - cache_creation_input_tokens, reasoning_output_tokens, usage_total_tokens, total_tokens + cache_creation_input_tokens, reasoning_output_tokens, usage_total_tokens, total_tokens, attempts_json FROM entries """ guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return nil } @@ -275,6 +275,10 @@ public struct OpenCodexUsageStore: Sendable { cacheCreationInputTokens: Self.int(statement, 12), reasoningOutputTokens: Self.int(statement, 13), totalTokens: Self.int(statement, 14)) + guard let attemptsJSON = Self.text(statement, 16), + let attemptsData = attemptsJSON.data(using: .utf8), + let attempts = try? JSONDecoder().decode([OpenCodexUsageAttempt].self, from: attemptsData) + else { return nil } entries.append(OpenCodexUsageEntry( requestID: requestID, timestamp: Date(timeIntervalSince1970: sqlite3_column_double(statement, 1)), @@ -285,7 +289,8 @@ public struct OpenCodexUsageStore: Sendable { surface: Self.text(statement, 6), conversationID: Self.text(statement, 7), usage: usage, - totalTokens: Self.int(statement, 15))) + totalTokens: Self.int(statement, 15), + attempts: attempts)) } step = sqlite3_step(statement) } @@ -427,7 +432,8 @@ public struct OpenCodexUsageStore: Sendable { cache_creation_input_tokens INTEGER, reasoning_output_tokens INTEGER, usage_total_tokens INTEGER, - total_tokens INTEGER + total_tokens INTEGER, + attempts_json TEXT NOT NULL ); """ guard sqlite3_exec(db, sql, nil, nil, nil) == SQLITE_OK else { return } @@ -440,8 +446,8 @@ public struct OpenCodexUsageStore: Sendable { INSERT OR REPLACE INTO entries( request_id, timestamp, provider, model, usage_status, account_label, surface, conversation_id, input_tokens, output_tokens, cached_input_tokens, cache_read_input_tokens, - cache_creation_input_tokens, reasoning_output_tokens, usage_total_tokens, total_tokens - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + cache_creation_input_tokens, reasoning_output_tokens, usage_total_tokens, total_tokens, attempts_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """ guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return false } defer { sqlite3_finalize(statement) } @@ -464,6 +470,10 @@ public struct OpenCodexUsageStore: Sendable { Self.bind(statement, 14, entry.usage?.reasoningOutputTokens) Self.bind(statement, 15, entry.usage?.totalTokens) Self.bind(statement, 16, entry.totalTokens) + guard let attemptsData = try? JSONEncoder().encode(entry.attempts), + let attemptsJSON = String(data: attemptsData, encoding: .utf8) + else { return false } + Self.bind(statement, 17, attemptsJSON) guard sqlite3_step(statement) == SQLITE_DONE else { return false } } return true diff --git a/Tests/CodexBarTests/GrokOpenCodexUsageTests.swift b/Tests/CodexBarTests/GrokOpenCodexUsageTests.swift new file mode 100644 index 0000000000..5a0e793f2e --- /dev/null +++ b/Tests/CodexBarTests/GrokOpenCodexUsageTests.swift @@ -0,0 +1,201 @@ +import Foundation +import SQLite3 +import Testing +@testable import CodexBar +@testable import CodexBarCore + +struct GrokOpenCodexUsageTests { + private static let now = Date(timeIntervalSince1970: 1_787_270_400) + private static let calendar = CostUsageBucketTimeZone.calendar(identifier: "UTC") + private static let pricing = CostUsageCustomPricing( + entries: ["xai/grok-test": .init(input: 2, output: 10)], fingerprint: "grok-test") + + @Test func `mixed attempts attribute only reported OAuth usage without the parent total`() throws { + let entry = try Self.entry(attempts: [ + Self.attempt(ordinal: 1), + Self.attempt(ordinal: 2, source: "xai-api-key"), + Self.attempt(ordinal: 3, provider: "openai"), + ]) + let snapshot = try #require(Self.snapshots([entry, entry])[.grok]) + #expect(snapshot.last30DaysTokens == 5) + #expect(snapshot.daily.first?.totalTokens == 5) + #expect(snapshot.sessions.first?.totalTokens == 5) + #expect(snapshot.costProvenance == .listPriceEstimate) + #expect(snapshot.daily.first?.coverageCounts.priced == 0) + #expect(snapshot.daily.first?.estimatedRequestCount == 1) + let cost = try #require(snapshot.last30DaysCostUSD) + #expect(abs(cost - 0.000026) < 0.000000000001) + } + + @Test func `legacy unknown API key and malformed attempts do not enter the subscription`() throws { + let rejected: [[String: Any]] = [ + Self.attempt(source: nil), Self.attempt(source: "oauth"), Self.attempt(source: "xai-api-key"), + Self.attempt(provider: "custom"), Self.attempt(model: "openai/grok-test"), + Self.attempt(changes: ["sendCount": 0]), Self.attempt(changes: ["locallyAnswered": true]), + Self.attempt(changes: ["usageStatus": "estimated"]), + Self.attempt(changes: ["usageStatus": "unreported"]), + Self.attempt(changes: ["ordinal": true]), Self.attempt(changes: ["ordinal": 1.5]), + Self.attempt(changes: ["sendCount": 1.5]), + ] + for attempt in rejected { + #expect(try Self.snapshots([Self.entry(attempts: [attempt])])[.grok] == nil) + } + let forgedTop = try Self.entry(attempts: [], changes: ["credentialSource": "grok-oauth"]) + #expect(Self.snapshots([forgedTop])[.grok] == nil) + let duplicates = try Self.entry(attempts: [Self.attempt(), Self.attempt()]) + #expect(Self.snapshots([duplicates])[.grok] == nil) + } + + @Test func `latest request replaces earlier attribution before subscription grouping`() throws { + let earlier = try Self.entry(attempts: [Self.attempt()]) + let latest = try Self.entry(attempts: [Self.attempt(source: "xai-api-key")]) + #expect(Self.snapshots([earlier, latest])[.grok] == nil) + } + + @Test func `missing token classes and unknown model prices retain tokens without dollars`() throws { + for attempt in [ + Self.attempt(changes: ["usage": ["totalTokens": 5]]), + Self.attempt(model: "grok-fictional-unpriced"), + ] { + let snapshot = try #require(try Self.snapshots([Self.entry(attempts: [attempt])])[.grok]) + #expect(snapshot.last30DaysTokens == 5) + #expect(snapshot.last30DaysCostUSD == nil) + #expect(snapshot.daily.first?.unpricedRequestCount == 1) + #expect(snapshot.daily.first?.estimatedRequestCount == 0) + } + } + + @Test func `attempt provenance survives cache reopen incremental append and v2 rebuild`() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let log = root.appendingPathComponent("usage.jsonl") + let cache = root.appendingPathComponent("cache") + let firstLine = try Self.line(attempts: [Self.attempt()]) + "\n" + try Data(firstLine.utf8).write(to: log) + let store = OpenCodexUsageStore(cacheRoot: cache) + let first = try store.loadEntries(logURL: log) + let reopened = OpenCodexUsageStore(cacheRoot: cache) + let recorder = OpenCodexUsageParser.LogReadRecorder() + let cached = try OpenCodexUsageStore.withLogReadRecorderForTesting(recorder) { + try reopened.loadEntries(logURL: log) + } + #expect(cached == first) + #expect(recorder.snapshot().bytesRead == 0) + #expect(cached.first?.attempts.first?.credentialSource == .grokOAuth) + + let handle = try FileHandle(forWritingTo: log) + try handle.seekToEnd() + try handle.write(contentsOf: Data((Self.line( + attempts: [Self.attempt(source: "xai-api-key")], changes: ["requestId": "api-key"]) + "\n").utf8)) + try handle.close() + let appended = try reopened.loadEntries(logURL: log) + #expect(appended.count == 2) + #expect(Self.snapshots(appended)[.grok]?.last30DaysTokens == 5) + + // A pre-provenance cache must re-read the raw log even when file size and mtime are unchanged. + var database: OpaquePointer? + let path = cache.appendingPathComponent(OpenCodexUsageStore.databaseFilename).path + #expect(sqlite3_open(path, &database) == SQLITE_OK) + #expect(sqlite3_exec(database, "PRAGMA user_version = 2", nil, nil, nil) == SQLITE_OK) + sqlite3_close(database) + let rebuilt = try OpenCodexUsageStore(cacheRoot: cache).loadEntries(logURL: log) + #expect(rebuilt == appended) + #expect(Self.snapshots(rebuilt)[.grok]?.last30DaysTokens == 5) + } + + @Test func `Grok merge preserves recorded and estimated coverage across date filters`() throws { + let supplement = try #require(try Self.snapshots([Self.entry(attempts: [Self.attempt()])])[.grok]) + let yesterday = Self.now.addingTimeInterval(-86400) + let nativeDay = CostUsageDailyReport.Entry( + date: CostUsageLocalDay.key(from: yesterday, calendar: Self.calendar), + inputTokens: 8, + outputTokens: 2, + totalTokens: 10, + costUSD: 0.1, + modelsUsed: nil, + modelBreakdowns: nil, + pricedRequestCount: 1) + let native = CostUsageFetcher.tokenSnapshot( + from: CostUsageDailyReport(data: [nativeDay], summary: nil), + now: Self.now, + historyDays: 7, + calendar: Self.calendar, + costProvenance: .vendorMetered) + let merged = OpenCodexUsageFanOut.mergeSnapshots( + native, supplement, now: Self.now, historyDays: 7, calendar: Self.calendar, provider: .grok) + #expect(merged.last30DaysTokens == 15) + #expect(merged.costProvenance == .mixed) + #expect(GrokLocalSessionSummary.costProvenance(for: [nativeDay], fallback: merged.costProvenance) + == .vendorMetered) + #expect(GrokLocalSessionSummary.costProvenance(for: supplement.daily, fallback: merged.costProvenance) + == .listPriceEstimate) + } + + @Test func `dashboard publishes OAuth attempts only when the existing OpenCodex switch is on`() throws { + let entry = try Self.entry(attempts: [Self.attempt()]) + for enabled in [false, true] { + let config = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.grok.rawValue], + codexAccountIdentities: [], + bucketTimeZoneIdentifier: "UTC", + openCodexUsageLogsEnabled: enabled) + let request = SpendDashboardLoadRequest( + configuration: config, + capturedInputs: [], + unavailableSourceIDs: [], + confirmedEmptySourceIDs: [], + codexRequests: [], + now: Self.now, + force: false) + let result = SpendDashboardSource.mergingOpenCodexInputsWithObservation( + [], + request: request, + environment: ["OPENCODEX_HOME": "/synthetic/opencodex"], + entryLoader: { _ in [entry] }) + #expect(result.inputs.count == (enabled ? 1 : 0)) + if enabled { + #expect(result.inputs.first?.provider == .grok) + #expect(result.inputs.first?.sourceKind == .openCodex) + #expect(result.inputs.first?.snapshot.last30DaysTokens == 5) + } + } + } + + private static func snapshots(_ entries: [OpenCodexUsageEntry]) -> [UsageProvider: CostUsageTokenSnapshot] { + OpenCodexUsageFanOut.snapshotsBySubscription( + entries: entries, now: self.now, historyDays: 7, calendar: self.calendar, customPricing: self.pricing) + } + + private static func attempt( + ordinal: Int = 1, + provider: String = "xai", + model: String = "grok-test", + source: String? = "grok-oauth", + changes: [String: Any] = [:]) -> [String: Any] + { + var row: [String: Any] = [ + "ordinal": ordinal, "provider": provider, "model": model, "adapter": "openai-responses", + "status": 200, "durationMs": 1, "sendCount": 1, "recoveryKinds": [], "usageStatus": "reported", + "usage": ["inputTokens": 3, "outputTokens": 2, "totalTokens": 5], "totalTokens": 5, + ] + row["credentialSource"] = source + row.merge(changes, uniquingKeysWith: { _, latest in latest }) + return row + } + + private static func line(attempts: [[String: Any]], changes: [String: Any] = [:]) throws -> String { + var row: [String: Any] = [ + "requestId": "ocx-mixed", "timestamp": self.now.timeIntervalSince1970 * 1000, + "provider": "combo", "model": "combo/test", "usageStatus": "reported", "status": 200, + "durationMs": 1, "totalTokens": 15000, "attempts": attempts, + ] + row.merge(changes, uniquingKeysWith: { _, latest in latest }) + return try #require(String(data: JSONSerialization.data(withJSONObject: row), encoding: .utf8)) + } + + private static func entry(attempts: [[String: Any]], changes: [String: Any] = [:]) throws -> OpenCodexUsageEntry { + try #require(OpenCodexUsageParser.parseLine(self.line(attempts: attempts, changes: changes))) + } +} diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 23aca44db6..5ece5a4f02 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -3835,7 +3835,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift", - line: 545, + line: 543, anchor: "if self.codex[trimmed] != nil {", expectedProviderIDs: ["codex"], expectedReferenceCount: 2, @@ -3843,7 +3843,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift", - line: 588, + line: 586, anchor: "if self.claude[base] != nil {", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3851,7 +3851,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift", - line: 621, + line: 619, anchor: "let bundled = lookup.pricing.providerID == self.codexModelsDevProviderID ? self.codex[key] : nil", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3859,7 +3859,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift", - line: 655, + line: 653, anchor: "guard let pricing = self.codex[key] else { return nil }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3867,7 +3867,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift", - line: 810, + line: 808, anchor: "guard let pricing = self.claude[key] else { return nil }", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, diff --git a/docs/grok.md b/docs/grok.md index 65edf7ec87..6a12cfc43a 100644 --- a/docs/grok.md +++ b/docs/grok.md @@ -182,11 +182,20 @@ The grok.com billing gRPC-web endpoint remains a best-effort fallback. ## OpenCodex usage -OpenCodex `xai` traffic is not merged into the Grok subscription row. The usage log -does not retain whether each request used Grok OAuth or an xAI API key, and the current -provider config cannot safely reclassify historical records. The Grok provider and -**Usage & Spend** therefore use only the native Grok CLI session logs until OpenCodex -records credential provenance at request time. +With **Include OpenCodex usage logs** enabled (off by default), **Usage & Spend** +adds reported Grok OAuth attempts from OpenCodex to the Grok row. This requires an +OpenCodex version that writes `attempts[].credentialSource: "grok-oauth"` from the +resolved upstream transport. API-key attempts, historic records without provenance, +unreported or estimated token counts, and locally answered requests stay excluded. +Current credentials, inbound API keys, and model names never backfill attribution. + +Each physical attempt contributes its own reported token counts, including a metered +attempt before a combo switches providers. The parent aggregate is not counted again. +OpenCodex has no recorded dollar amount: its costs use public list prices and remain +estimates, with separate coverage when combined with native CLI-recorded spend. +Missing token classes or unknown prices retain tokens without inventing a dollar value. +The Grok menu continues to use native CLI logs; this opt-in integration is for +**Usage & Spend**. Neither source is a subscription invoice. ## JSON-RPC contract From 231714c0496ee35adf495419b3219be5606eb469 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Sat, 5 Sep 2026 02:13:34 -0700 Subject: [PATCH 31/34] Preserve standalone prices and cancel Grok scans promptly --- Scripts/capture_grok_opencodex_proof.py | 82 +++++++++++++++ .../SpendDashboardSource+OpenCodex.swift | 3 +- .../Grok/GrokLocalSessionScanner.swift | 62 ++++++++---- .../OpenCodexRouteDispatcher.swift | 12 +-- .../OpenCodexUsageAggregator.swift | 10 +- .../CostUsageScanExecutorTests.swift | 53 +++++++++- .../Fixtures/GrokOpenCodex/usage.jsonl | 2 + .../GrokOpenCodexUsageTests.swift | 99 +++++++++++++++++++ .../grok-opencodex-producer-2026-09-05.md | 67 +++++++++++++ 9 files changed, 354 insertions(+), 36 deletions(-) create mode 100755 Scripts/capture_grok_opencodex_proof.py create mode 100644 Tests/CodexBarTests/Fixtures/GrokOpenCodex/usage.jsonl create mode 100644 docs/evidence/grok-opencodex-producer-2026-09-05.md diff --git a/Scripts/capture_grok_opencodex_proof.py b/Scripts/capture_grok_opencodex_proof.py new file mode 100755 index 0000000000..e4f3dc8ee0 --- /dev/null +++ b/Scripts/capture_grok_opencodex_proof.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Capture real OpenCodex usage-writer output using its isolated upstream fixtures.""" + +import argparse +import hashlib +import json +import pathlib +import re +import subprocess + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--opencodex-root", required=True, type=pathlib.Path) + parser.add_argument("--output-dir", required=True, type=pathlib.Path) + args = parser.parse_args() + root = args.opencodex_root.resolve() + expected = "146ed679c9633e5d68726217fcadc8e0b107339b" + head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=root, text=True).strip() + dirty = subprocess.check_output(["git", "status", "--porcelain"], cwd=root, text=True).strip() + if head != expected or dirty: + parser.error("Use a clean OpenCodex checkout at " + expected) + output = args.output_dir.resolve() + output.mkdir(parents=True, exist_ok=False) + ledger = output / "usage.jsonl" + source = root / "tests/server/server-xai-oauth-401-replay.test.ts" + original = source.read_text() + captured_test = original.replace( + "mkdtempSync, readFileSync}", + "mkdtempSync, readFileSync, existsSync, appendFileSync}", + 1, + ).replace( + "afterEach(() => {", + "afterEach(() => {\n if (existsSync(usageLogPath())) appendFileSync(" + + json.dumps(str(ledger)) + ", readFileSync(usageLogPath()));", + 1, + ).replace( + "return originalFetch(input, init);", + 'throw new Error("Unexpected upstream request in isolated producer proof");', + ) + captured_test = re.sub( + r'from "(\.[^"]+)"', + lambda match: "from " + json.dumps(str((source.parent / match[1]).resolve())), + captured_test, + ) + test_file = output / "producer-proof.test.ts" + test_file.write_text(captured_test) + bun = root / "node_modules/.bin/bun" + version = subprocess.check_output([str(bun), "--version"], cwd=root, text=True).strip() + if version != "1.4.0": + parser.error("Use repository-pinned Bun 1.4.0") + with (output / "terminal.log").open("w") as terminal: + subprocess.run( + [str(bun), "test", str(test_file), "--test-name-pattern", + "401 then 200 performs one refresh and one replay|native Chat records canonical API-key provenance"], + cwd=root, stdout=terminal, stderr=subprocess.STDOUT, check=True, + ) + raw = ledger.read_bytes() + rows = [json.loads(line) for line in raw.splitlines()] + assert len(rows) == 2 + assert all(secret not in raw for secret in [ + b"rejected-access", b"fresh-access", b"initial-refresh", b"xai-test-account", b"Bearer ", + ]) + attempts = [attempt for row in rows for attempt in row["attempts"]] + assert {row["credentialSource"] for row in attempts} == {"grok-oauth", "xai-api-key"} + result = { + "producerRepository": "https://github.com/lidge-jun/opencodex", + "producerCommit": head, + "bunVersion": version, + "upstream": "isolated fixtures; no live provider requests", + "sha256": hashlib.sha256(raw).hexdigest(), + "rows": len(rows), + "attempts": [{key: row[key] for key in [ + "credentialSource", "adapter", "sendCount", "totalTokens", + ]} for row in attempts], + } + (output / "capture.json").write_text(json.dumps(result, indent=2) + "\n") + print(json.dumps(result, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift b/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift index 84150b4a48..0045ebbdcf 100644 --- a/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift +++ b/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift @@ -13,6 +13,7 @@ extension SpendDashboardSource { _ inputs: [SpendDashboardModel.ProviderInput], request: SpendDashboardLoadRequest, environment: [String: String] = ProcessInfo.processInfo.environment, + cacheRoot: URL? = nil, entryLoader: ((URL) throws -> [OpenCodexUsageEntry])? = nil) -> ( inputs: [SpendDashboardModel.ProviderInput], observation: SpendDashboardLoadResult.OpenCodexObservation) @@ -27,7 +28,7 @@ extension SpendDashboardSource { guard let logURL = OpenCodexUsageLog.usageLogURL(environment: environment) else { return (inputs.filter { $0.id != SpendDashboardModel.openCodexSourceID }, .unavailable) } - let store = OpenCodexUsageStore(cacheRoot: OpenCodexUsageLog.cacheRoot()) + let store = OpenCodexUsageStore(cacheRoot: cacheRoot ?? OpenCodexUsageLog.cacheRoot()) let entries: [OpenCodexUsageEntry] do { entries = try entryLoader?(logURL) ?? store.loadEntries(logURL: logURL) diff --git a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift index 4c25110d5e..54b568fc25 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift @@ -490,7 +490,8 @@ public enum GrokLocalSessionScanner { fileManager: .default, lookbackDays: lookbackDays, now: now, - pricing: pricing) + pricing: pricing, + checkCancellation: checkCancellation) try checkCancellation() return summary } @@ -535,7 +536,9 @@ public enum GrokLocalSessionScanner { modelsDevCatalog: ModelsDevCatalog, modelsDevCacheRoot: URL? = nil, customPricing: CostUsageCustomPricing? = .empty, - scanLimits: GrokLocalSessionScanLimits = .production) -> GrokLocalSessionSummary + scanLimits: GrokLocalSessionScanLimits = .production, + checkCancellation: @escaping @Sendable () throws -> Void = { try Task.checkCancellation() }) + -> GrokLocalSessionSummary { self.summarize( env: env, @@ -546,7 +549,8 @@ public enum GrokLocalSessionScanner { modelsDevCatalog: modelsDevCatalog, modelsDevCacheRoot: modelsDevCacheRoot, customPricing: customPricing), - scanLimits: scanLimits) + scanLimits: scanLimits, + checkCancellation: checkCancellation) } static func summarize( @@ -574,7 +578,9 @@ public enum GrokLocalSessionScanner { lookbackDays: Int, now: Date, pricing: PricingContext, - scanLimits: GrokLocalSessionScanLimits = .production) -> GrokLocalSessionSummary + scanLimits: GrokLocalSessionScanLimits = .production, + checkCancellation: @escaping @Sendable () throws -> Void = { try Task.checkCancellation() }) + -> GrokLocalSessionSummary { let root = GrokCredentialsStore.grokHomeURL(env: env, fileManager: fileManager) .appendingPathComponent("sessions", isDirectory: true) @@ -593,8 +599,8 @@ public enum GrokLocalSessionScanner { root: root, fileManager: fileManager, lookbackCutoff: lookbackCutoff, - maximumCount: scanLimits.maximumSessions, - maximumDiscoveryEntries: scanLimits.maximumDiscoveryEntries) + limits: scanLimits, + checkCancellation: checkCancellation) else { return self.emptySummary(now: now) } @@ -607,7 +613,7 @@ public enum GrokLocalSessionScanner { var historyCoverageIsEstablished = sessionSelection.historyCoverageIsEstablished for sessionPath in sessionSelection.paths { - guard !Task.isCancelled else { return self.emptySummary(now: now) } + guard !self.cancellationRequested(checkCancellation) else { return self.emptySummary(now: now) } guard remainingTotalBytes > 0, remainingTotalTurns > 0 else { historyCoverageIsEstablished = false break @@ -628,9 +634,13 @@ public enum GrokLocalSessionScanner { mtimeIntervalSince1970: identity.modificationDate.timeIntervalSince1970, limits: fileLimits) { - self.decodeTurns(at: updates, fileSize: identity.size, limits: fileLimits) + self.decodeTurns( + at: updates, + fileSize: identity.size, + limits: fileLimits, + checkCancellation: checkCancellation) } - guard !Task.isCancelled else { return self.emptySummary(now: now) } + guard !self.cancellationRequested(checkCancellation) else { return self.emptySummary(now: now) } historyCoverageIsEstablished = historyCoverageIsEstablished && parsed.historyCoverageIsEstablished updatesYieldedCompletedTurns = !parsed.turns.isEmpty @@ -643,7 +653,7 @@ public enum GrokLocalSessionScanner { if !currentTurns.isEmpty { sessionCount += 1 for turn in currentTurns { - guard !Task.isCancelled else { return self.emptySummary(now: now) } + guard !self.cancellationRequested(checkCancellation) else { return self.emptySummary(now: now) } if turn.timestamp > (lastSessionAt ?? Date.distantPast) { lastSessionAt = turn.timestamp } @@ -714,7 +724,13 @@ public enum GrokLocalSessionScanner { { try await CostUsageScanExecutor.run { checkCancellation in try checkCancellation() - let summary = Self.summarize(env: env, lookbackDays: lookbackDays, now: now) + let summary = Self.summarize( + env: env, + fileManager: .default, + lookbackDays: lookbackDays, + now: now, + pricing: PricingContext(modelsDevCatalog: nil, modelsDevCacheRoot: nil, customPricing: .empty), + checkCancellation: checkCancellation) try checkCancellation() return summary } @@ -740,6 +756,15 @@ public enum GrokLocalSessionScanner { self.parseCache.cachedTurnCount() } + private static func cancellationRequested(_ checkCancellation: () throws -> Void) -> Bool { + do { + try checkCancellation() + return false + } catch { + return true + } + } + private static func emptySummary(now: Date) -> GrokLocalSessionSummary { GrokLocalSessionSummary( sessionCount: 0, @@ -763,8 +788,8 @@ public enum GrokLocalSessionScanner { root: URL, fileManager: FileManager, lookbackCutoff: Date, - maximumCount: Int, - maximumDiscoveryEntries: Int) -> RecentSessionSelection? + limits: GrokLocalSessionScanLimits, + checkCancellation: () throws -> Void) -> RecentSessionSelection? { guard let rootEnum = fileManager.enumerator( at: root, @@ -772,6 +797,8 @@ public enum GrokLocalSessionScanner { options: [.skipsHiddenFiles]) else { return nil } + let maximumCount = limits.maximumSessions + let maximumDiscoveryEntries = limits.maximumDiscoveryEntries var sessionModificationDates: [String: Date] = [:] var historyCoverageIsEstablished = true let trimThreshold = maximumCount > Int.max / 2 ? Int.max : maximumCount * 2 @@ -780,7 +807,7 @@ public enum GrokLocalSessionScanner { let url = rootEnum.nextObject() as? URL { discoveryEntryCount += 1 - guard !Task.isCancelled else { return nil } + guard !self.cancellationRequested(checkCancellation) else { return nil } let name = url.lastPathComponent guard name == "updates.jsonl" || name == "signals.json" else { continue } guard let identity = self.fileIdentity(for: url), @@ -826,7 +853,8 @@ public enum GrokLocalSessionScanner { private static func decodeTurns( at url: URL, fileSize: Int, - limits: GrokLocalSessionScanLimits) -> GrokTurnDecodeResult + limits: GrokLocalSessionScanLimits, + checkCancellation: @escaping @Sendable () throws -> Void) -> GrokTurnDecodeResult { var turns: [GrokParsedTurn] = [] var jsonDecodeCount = 0 @@ -846,9 +874,7 @@ public enum GrokLocalSessionScanner { maxLineBytes: limits.maximumLineBytes, prefixBytes: limits.maximumLineBytes, maxBytesToRead: limits.maximumFileBytes, - checkCancellation: { - if Task.isCancelled { throw CancellationError() } - }, + checkCancellation: checkCancellation, onLine: { line in guard line.bytes.range(of: self.turnCompletedNeedle) != nil else { return } jsonDecodeCount += 1 diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexRouteDispatcher.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexRouteDispatcher.swift index 3de8f3eacd..8b579891a4 100644 --- a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexRouteDispatcher.swift +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexRouteDispatcher.swift @@ -7,14 +7,6 @@ public enum OpenCodexRouteTarget: Equatable, Sendable { } public enum OpenCodexRouteDispatcher { - static func route(entry: OpenCodexUsageEntry) -> OpenCodexRouteTarget { - // Provider-specific by design: only derived, request-time OAuth attempts enter Grok's subscription. - if entry.provider == "xai", entry.credentialSource == .grokOAuth { - return .subscription(.grok) - } - return self.route(provider: entry.provider, modelName: entry.model) - } - public static func route(provider: String) -> OpenCodexRouteTarget { // Provider-specific by design: OpenCodex provider prefixes map onto subscription rows or token-only spend. let providerID = provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() @@ -22,8 +14,8 @@ public enum OpenCodexRouteDispatcher { case "openai": return .subscription(.codex) case "xai": - // Legacy rows and API-key traffic have no subscription attribution. Only the entry-aware - // overload accepts Grok OAuth provenance derived from a physical attempt. + // Legacy rows and API-key traffic have no subscription attribution. Only the + // per-attempt fan-out accepts explicit Grok OAuth provenance. return .tokenOnly case "opencode-go": return .subscription(.opencodego) diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift index 285b15be9c..68e7d43074 100644 --- a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift @@ -347,12 +347,10 @@ enum OpenCodexUsageAggregator { modelsDevCatalog: ModelsDevCatalog, customPricingOverlay: CostUsageCustomPricing) -> Double? { - // Provider-specific by design: token-only routes lack the request-time credential provenance needed to - // decide whether their traffic belongs to a subscription or an API bill. Keep their standalone OpenCodex - // rows token-only too instead of attaching a dollar amount that the subscription fan-out intentionally drops. - guard OpenCodexRouteDispatcher.route(entry: entry) != .tokenOnly else { - return nil - } + // Provider-specific by design: unknown xAI provenance cannot establish Grok subscription spend. + // Other token-only routes still retain standalone catalog and custom pricing. + let isXAI = entry.provider.lowercased() == "xai" || entry.model.lowercased().hasPrefix("xai/") + if isXAI, entry.credentialSource != .grokOAuth { return nil } guard entry.usageStatus == .reported || entry.usageStatus == .estimated else { return nil } let usage = entry.usage let hasTokenData = entry.resolvedTotalTokens != nil diff --git a/Tests/CodexBarTests/CostUsageScanExecutorTests.swift b/Tests/CodexBarTests/CostUsageScanExecutorTests.swift index 8ea5a03b44..77c36b7556 100644 --- a/Tests/CodexBarTests/CostUsageScanExecutorTests.swift +++ b/Tests/CodexBarTests/CostUsageScanExecutorTests.swift @@ -2,7 +2,7 @@ import Foundation import Testing @testable import CodexBarCore -struct CostUsageScanExecutorTests { +struct CostUsageScanExecutorTests: GrokLocalSessionScannerTestSupport { @Test func `runs work on the dedicated scan queue and returns its value`() async throws { let queue = self.makeQueue() @@ -112,6 +112,57 @@ struct CostUsageScanExecutorTests { _ = try? await blocker.value } + @Test + func `Grok JSONL parsing observes executor cancellation after scanning starts`() async throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let now = Date() + let turn = self.turn(timestamp: now, usage: self.singleModelUsage(input: 3, output: 2)) + let line = try #require(String(data: JSONSerialization.data(withJSONObject: turn), encoding: .utf8)) + "\n" + let lineCount = 40000 + try Data(String(repeating: line, count: lineCount).utf8) + .write(to: fixture.session.appendingPathComponent("updates.jsonl")) + let queue = self.makeQueue() + let checks = LockedValue(0) + let parsingStarted = LockedValue(false) + let releaseParser = LockedValue(false) + let root = fixture.root + let task = Task { + try await CostUsageScanExecutor.run(on: queue) { checkCancellation in + try GrokLocalSessionScanner.summarize( + env: ["GROK_HOME": root.path], + now: now, + modelsDevCatalog: Self.catalog(), + checkCancellation: { + checks.update { $0 += 1 } + // One session has fewer than 10 discovery checks; this barrier is inside chunk parsing. + if checks.value == 32 { + parsingStarted.set(true) + while !releaseParser.value { + Thread.sleep(forTimeInterval: 0.001) + } + } + try checkCancellation() + }) + } + } + let reachedParser = await self.waitUntil(timeout: .seconds(3)) { parsingStarted.value } + #expect(reachedParser) + let cancelledAt = ContinuousClock.now + task.cancel() + releaseParser.set(true) + await #expect(throws: CancellationError.self) { try await task.value } + let nextScanRan = try await CostUsageScanExecutor.run(on: queue) { _ in true } + let cancellationLatency = cancelledAt.duration(to: .now) + #expect(nextScanRan) + #expect(cancellationLatency < .seconds(1)) + let decoded = GrokLocalSessionScanner.parseCacheMetricsForTesting(pathPrefix: root.path).jsonDecodeCount + #expect(decoded > 0) + #expect(decoded < lineCount) + print("grok_cancel_decoded_lines=\(decoded)/\(lineCount)") + print("grok_cancel_queue_release=\(cancellationLatency)") + } + private func makeQueue() -> DispatchQueue { DispatchQueue(label: "\(CostUsageScanExecutor.queueLabel).tests.\(UUID().uuidString)") } diff --git a/Tests/CodexBarTests/Fixtures/GrokOpenCodex/usage.jsonl b/Tests/CodexBarTests/Fixtures/GrokOpenCodex/usage.jsonl new file mode 100644 index 0000000000..0753939bdb --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/GrokOpenCodex/usage.jsonl @@ -0,0 +1,2 @@ +{"requestId":"ocx-39907256af2ab94ec49657f775121717","timestamp":1788597898129,"provider":"xai","model":"grok-4.5","admissionKind":"loopback","inboundProtocol":"responses","accountLogLabel":"oecc4c4","resolvedModel":"grok-4.5","requestedModel":"xai/grok-4.5","modelSupportsServiceTier":false,"tierOutcome":{"wireKind":null,"wireValue":null,"fastOutcome":"unknown","confirmation":"unknown"},"status":200,"durationMs":21,"usageStatus":"reported","usage":{"inputTokens":3,"outputTokens":2,"totalTokens":5},"totalTokens":5,"attempts":[{"ordinal":1,"provider":"xai","credentialSource":"grok-oauth","model":"grok-4.5","adapter":"openai-responses","status":200,"durationMs":8,"sendCount":2,"recoveryKinds":["oauth-401"],"usageStatus":"reported","accountLogLabel":"oecc4c4","usage":{"inputTokens":3,"outputTokens":2,"totalTokens":5},"totalTokens":5,"tierOutcome":{"wireKind":null,"wireValue":null,"fastOutcome":"unknown","confirmation":"unknown"}}],"routeDecision":{"version":1,"decisionId":"d4ec9bf70dd5","createdAt":1788597898139,"requestedModel":"xai/grok-4.5","routeKind":"explicit-provider","requirements":[],"candidates":[{"provider":"xai","model":"grok-4.5","eligible":true,"exclusions":[]}],"selected":{"candidateIndex":0,"provider":"xai","model":"grok-4.5","reason":"explicit-provider-namespace"}}} +{"requestId":"ocx-5dba1b394c1c4a4ef822482e9856c31d","timestamp":1788597898158,"provider":"xai","model":"grok-4.5","admissionKind":"loopback","inboundProtocol":"chat","requestedModel":"xai/grok-4.5","status":200,"durationMs":2,"firstOutputMs":2,"usageStatus":"reported","usage":{"inputTokens":3,"outputTokens":2},"totalTokens":5,"attempts":[{"ordinal":1,"provider":"xai","credentialSource":"xai-api-key","model":"grok-4.5","adapter":"openai-chat","status":200,"durationMs":0,"firstOutputMs":0,"sendCount":1,"recoveryKinds":[],"usageStatus":"reported","usage":{"inputTokens":3,"outputTokens":2},"totalTokens":5}],"routeDecision":{"version":1,"decisionId":"40ee37e00887","createdAt":1788597898159,"requestedModel":"xai/grok-4.5","routeKind":"explicit-provider","requirements":[],"candidates":[{"provider":"xai","model":"grok-4.5","eligible":true,"exclusions":[]}],"selected":{"candidateIndex":0,"provider":"xai","model":"grok-4.5","reason":"explicit-provider-namespace"}}} diff --git a/Tests/CodexBarTests/GrokOpenCodexUsageTests.swift b/Tests/CodexBarTests/GrokOpenCodexUsageTests.swift index 5a0e793f2e..6aae552077 100644 --- a/Tests/CodexBarTests/GrokOpenCodexUsageTests.swift +++ b/Tests/CodexBarTests/GrokOpenCodexUsageTests.swift @@ -1,3 +1,4 @@ +import CryptoKit import Foundation import SQLite3 import Testing @@ -163,6 +164,104 @@ struct GrokOpenCodexUsageTests { } } + @Test func `non Grok token only routes keep standalone zero and custom prices`() throws { + for provider in ["opencode", "opencode-free"] { + let entry = OpenCodexUsageEntry( + requestID: "standalone-\(provider)", + timestamp: Self.now, + provider: provider, + model: "deepseek-v4-flash-free", + usageStatus: .reported, + usage: OpenCodexTokenUsage(inputTokens: 3, outputTokens: 2, totalTokens: 5), + totalTokens: 5) + let freeCatalogJSON = """ + {"opencode":{"id":"opencode","models":{"deepseek-v4-flash-free":{ + "id":"deepseek-v4-flash-free","cost":{"input":0,"output":0}}}}} + """ + let catalog = try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(freeCatalogJSON.utf8)) + let free = OpenCodexUsageAggregator.snapshot( + entries: [entry], + now: Self.now, + historyDays: 7, + calendar: Self.calendar, + modelsDevCatalog: catalog, + customPricingOverlay: .empty) + #expect(free.last30DaysCostUSD == 0) + for inputRate in [0.0, 2.0] { + let pricing = CostUsageCustomPricing( + entries: ["\(provider)/deepseek-v4-flash-free": .init(input: inputRate, output: inputRate)], + fingerprint: "standalone-\(inputRate)") + let snapshot = OpenCodexUsageAggregator.snapshot( + entries: [entry], + now: Self.now, + historyDays: 7, + calendar: Self.calendar, + customPricing: pricing, + modelsDevCatalog: ModelsDevCatalog(providers: [:])) + let cost = try #require(snapshot.last30DaysCostUSD) + #expect(abs(cost - inputRate * 5 / 1_000_000) < 0.000000000001) + #expect(Self.snapshots([entry]).isEmpty) + } + } + } + + @Test func `captured producer ledger imports through the dashboard disk loader and cache`() throws { + let fixture = try #require(Bundle.module.url( + forResource: "usage", withExtension: "jsonl", subdirectory: "Fixtures/GrokOpenCodex")) + let captured = try Data(contentsOf: fixture) + let digest = SHA256.hash(data: captured).map { String(format: "%02x", $0) }.joined() + #expect(digest == "ef6d8758b40910f6e5993d5b5a105a2ad2834c6c1bd0565ab87b61cf091c4978") + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let log = root.appendingPathComponent("usage.jsonl") + try captured.write(to: log) + let proofNow = Date(timeIntervalSince1970: 1_788_597_900) + let config = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.grok.rawValue], + codexAccountIdentities: [], + bucketTimeZoneIdentifier: "UTC", + openCodexUsageLogsEnabled: true) + let request = SpendDashboardLoadRequest( + configuration: config, + capturedInputs: [], + unavailableSourceIDs: [], + confirmedEmptySourceIDs: [], + codexRequests: [], + now: proofNow, + force: false) + let cache = root.appendingPathComponent("cache") + let imported = SpendDashboardSource.mergingOpenCodexInputsWithObservation( + [], request: request, environment: ["OPENCODEX_HOME": root.path], cacheRoot: cache) + let snapshot = try #require(imported.inputs.first?.snapshot) + #expect(imported.inputs.count == 1) + #expect(imported.inputs.first?.provider == .grok) + #expect(snapshot.last30DaysTokens == 5) + #expect(snapshot.daily.first?.inputTokens == 3) + #expect(snapshot.daily.first?.outputTokens == 2) + let dashboard = SpendDashboardModel.build(inputs: imported.inputs, requestedDays: 7, now: proofNow) + #expect(dashboard.groups.first?.providers.first?.totalTokens == 5) + let recorder = OpenCodexUsageParser.LogReadRecorder() + let reopened = OpenCodexUsageStore.withLogReadRecorderForTesting(recorder) { + SpendDashboardSource.mergingOpenCodexInputsWithObservation( + [], request: request, environment: ["OPENCODEX_HOME": root.path], cacheRoot: cache) + } + #expect(reopened.inputs.first?.snapshot.last30DaysTokens == 5) + #expect(recorder.snapshot().bytesRead == 0) + // Keep the exact producer-written API-key line, without fabricating a replacement record. + let text = try #require(String(data: captured, encoding: .utf8)) + let keyLine = try #require(text.split(separator: "\n").first { $0.contains("\"xai-api-key\"") }) + try Data((keyLine + "\n").utf8).write(to: log) + let keyOnly = SpendDashboardSource.mergingOpenCodexInputsWithObservation( + [], request: request, environment: ["OPENCODEX_HOME": root.path], cacheRoot: cache) + #expect(keyOnly.inputs.isEmpty) + print("producer_capture_sha256=\(digest)") + print("producer_log_rows=2 total_reported_tokens=10 grok_oauth_tokens=5") + print("producer_import_dashboard_tokens=5 cache_reopen_bytes=\(recorder.snapshot().bytesRead)") + print("producer_api_key_only_subscription_rows=\(keyOnly.inputs.count)") + } + private static func snapshots(_ entries: [OpenCodexUsageEntry]) -> [UsageProvider: CostUsageTokenSnapshot] { OpenCodexUsageFanOut.snapshotsBySubscription( entries: entries, now: self.now, historyDays: 7, calendar: self.calendar, customPricing: self.pricing) diff --git a/docs/evidence/grok-opencodex-producer-2026-09-05.md b/docs/evidence/grok-opencodex-producer-2026-09-05.md new file mode 100644 index 0000000000..b36a8c1246 --- /dev/null +++ b/docs/evidence/grok-opencodex-producer-2026-09-05.md @@ -0,0 +1,67 @@ +# Grok / OpenCodex producer-to-dashboard evidence + +The captured ledger at `Tests/CodexBarTests/Fixtures/GrokOpenCodex/usage.jsonl` was written by the production OpenCodex server, request handlers, and durable usage logger at commit `146ed679c9633e5d68726217fcadc8e0b107339b` ([producer PR #3642](https://github.com/lidge-jun/opencodex/pull/3642)). Bun version: 1.4.0. + +Two localhost HTTP requests went through that server. OAuth credentials and the xAI/Grok and identity-provider responses came from OpenCodex's isolated upstream fixtures. Unexpected external requests were rejected. This capture exercises real routing, OAuth replay, native Chat dispatch, logging, file import, cache persistence, and dashboard projection; it is not evidence of live vendor authentication or billing. All identity labels belong to artificial fixture accounts. No user credentials or conversation text appear in the captured ledger. + +## Producer result + +The unmodified production handlers persisted these physical attempts: + +| Inbound request | Resolved adapter | Persisted credential source | Upstream sends | Reported tokens | +| --- | --- | --- | ---: | ---: | +| Responses, OAuth 401 then success | `openai-responses` | `grok-oauth` | 2 | 5 | +| Native Chat Completions, API key | `openai-chat` | `xai-api-key` | 1 | 5 | + +The source stamp comes after resolved transport/adapter selection in Responses, and from `activeProvider` when native Chat builds or rebuilds its outbound request. OpenCodex's persistence normalizer retains only the two fixed source values on `xai` attempts. The captured bytes retain the full production ledger shape, including attempts, recovery kinds, and route-decision metadata. + +Initial producer run: 2 tests passed, 23 assertions, zero failures. Captured ledger SHA-256: + +``` +ef6d8758b40910f6e5993d5b5a105a2ad2834c6c1bd0565ab87b61cf091c4978 +``` + +## CodexBar import result + +`GrokOpenCodexUsageTests` copies those exact bytes to an isolated `OPENCODEX_HOME/usage.jsonl`, supplies only an isolated cache directory, and calls the production dashboard disk loader. It supplies no injected entries or entry-loader closure. A second call constructs a new store and reads the persisted cache. A third import keeps only the exact producer-written API-key line. + +Captured terminal output from the passing consumer test: + +``` +producer_capture_sha256=ef6d8758b40910f6e5993d5b5a105a2ad2834c6c1bd0565ab87b61cf091c4978 +producer_log_rows=2 total_reported_tokens=10 grok_oauth_tokens=5 +producer_import_dashboard_tokens=5 cache_reopen_bytes=0 +producer_api_key_only_subscription_rows=0 +``` + +The production dashboard model displays 5 tokens. The API-key attempt contributes none to the Grok row. Existing focused regressions separately verify estimated dollars, mixed recorded/estimated date filters, malformed and historic records, and cache upgrades. + +## In-flight native scan cancellation + +`CostUsageScanExecutorTests` runs the Grok scanner through the actual executor on an isolated serial queue. A barrier after 32 cancellation checks confirms chunk parsing has started; the test then cancels the awaiting Swift task and queues another scan. Cancellation returns `CancellationError`, the second scan runs, and parsing stops before the full 40,000-line corpus. The test checks a one-second release bound. + +Observed result from the passing run: + +``` +grok_cancel_decoded_lines=4874/40000 +grok_cancel_queue_release=0.001392625 seconds +``` + +This is a controlled executor/scanner measurement, not an app-wide latency guarantee. Both production async Grok entry points now pass the executor callback through discovery, JSONL reads, and per-turn aggregation. Cancelled partial parses remain uncacheable and cannot establish complete history. + +## Reproduce + +Use a clean OpenCodex checkout at the pinned commit with its locked dependencies installed. The capture helper refuses another commit or a dirty checkout. It copies the upstream fixture runner, adds ledger export before fixture teardown, and rejects unexpected upstream fetches; production OpenCodex source is unchanged. + +```sh +python3 Scripts/capture_grok_opencodex_proof.py \ + --opencodex-root /path/to/pinned/opencodex \ + --output-dir /tmp/new-grok-producer-capture + +CODEXBAR_SUPPRESS_TEST_KEYCHAIN_ACCESS=1 \ +CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS=0 \ +CODEXBAR_TEST_CODEX_FILE_ISOLATION=1 \ +swift test --filter 'GrokOpenCodexUsageTests|CostUsageScanExecutorTests' +``` + +A second capture using the committed helper independently passed with the same sources, send counts, and token totals. Its SHA-256 was `f43560d58af9805185607a7643f836af4fb0bda15102678a9742b0977785f8d4`. Timestamps, request IDs, route-decision IDs, durations, and anonymous fixture labels vary between captures, so reproduction hashes are expected to differ. The checked-in consumer fixture is deliberately pinned to the initial bytes. From 81f1e2e0917425992c356e4a0413d390f6020a8a Mon Sep 17 00:00:00 2001 From: olddonkey Date: Sat, 5 Sep 2026 04:02:45 -0700 Subject: [PATCH 32/34] Preserve OpenCodex custom price overrides --- .../OpenCodexUsageAggregator.swift | 29 ++-- .../OpenCodexUsagePricingTests.swift | 131 ++++++++++++++++++ docs/grok.md | 6 + 3 files changed, 158 insertions(+), 8 deletions(-) create mode 100644 Tests/CodexBarTests/OpenCodexUsagePricingTests.swift diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift index 68e7d43074..cf8a44683b 100644 --- a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift @@ -338,19 +338,14 @@ enum OpenCodexUsageAggregator { /// List-price estimate for one entry. Precedence is unchanged from the per-merge pricing it replaces: /// 1. `customPricing` — the snapshot's own overlay (provider-scoped rates passed by the caller); - /// 2. `CostUsagePricing.codexCostUSD` with the pre-resolved `customPricingOverlay` (the app-level overlay file, - /// which `codexCostUSD` would otherwise re-load per call) and the pre-resolved models.dev `modelsDevCatalog` - /// (otherwise `ModelsDevCache.load` per call), then the bundled/historical tables. + /// 2. The pre-resolved app overlay, matching the original model before the provider-qualified model; + /// 3. The provider-qualified models.dev lookup, then the bundled/historical tables. private static func listPriceUSD( entry: OpenCodexUsageEntry, customPricing: CostUsageCustomPricing, modelsDevCatalog: ModelsDevCatalog, customPricingOverlay: CostUsageCustomPricing) -> Double? { - // Provider-specific by design: unknown xAI provenance cannot establish Grok subscription spend. - // Other token-only routes still retain standalone catalog and custom pricing. - let isXAI = entry.provider.lowercased() == "xai" || entry.model.lowercased().hasPrefix("xai/") - if isXAI, entry.credentialSource != .grokOAuth { return nil } guard entry.usageStatus == .reported || entry.usageStatus == .estimated else { return nil } let usage = entry.usage let hasTokenData = entry.resolvedTotalTokens != nil @@ -377,6 +372,24 @@ enum OpenCodexUsageAggregator { return overlay } let pricingModel = entry.model.contains("/") ? entry.model : "\(entry.provider)/\(entry.model)" + let overlayModel = customPricingOverlay.rates( + providerID: CostUsagePricing.codexModelsDevProviderID, model: entry.model) != nil + ? entry.model : pricingModel + if customPricingOverlay.rates( + providerID: CostUsagePricing.codexModelsDevProviderID, model: overlayModel) != nil + { + // Preserve bare-key precedence, cached-input accounting, and unknown rates in explicit overrides. + return customPricingOverlay.estimatedCodexCostUSD( + model: overlayModel, + inputTokens: input, + cachedInputTokens: cacheRead, + outputTokens: output, + cacheWriteInputTokens: cacheWrite) + } + // Provider-specific by design: raw xAI rows need an explicit price to establish a standalone estimate. + // Subscription attribution remains gated separately by physical Grok OAuth attempts in the fan-out. + let isXAI = entry.provider.lowercased() == "xai" || entry.model.lowercased().hasPrefix("xai/") + if isXAI, entry.credentialSource != .grokOAuth { return nil } return CostUsagePricing.codexCostUSD( model: pricingModel, inputTokens: input, @@ -385,7 +398,7 @@ enum OpenCodexUsageAggregator { cacheWriteInputTokens: cacheWrite, pricingDate: entry.timestamp, modelsDevCatalog: modelsDevCatalog, - customPricing: customPricingOverlay) + customPricing: .empty) } private static func add(_ lhs: Int?, _ rhs: Int?) -> Int? { diff --git a/Tests/CodexBarTests/OpenCodexUsagePricingTests.swift b/Tests/CodexBarTests/OpenCodexUsagePricingTests.swift new file mode 100644 index 0000000000..675f8386c5 --- /dev/null +++ b/Tests/CodexBarTests/OpenCodexUsagePricingTests.swift @@ -0,0 +1,131 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct OpenCodexUsagePricingTests { + private static let now = Date(timeIntervalSince1970: 1_787_270_400) + private static let calendar = CostUsageBucketTimeZone.calendar(identifier: "UTC") + + @Test func `application overlay preserves bare model precedence and cached token accounting`() throws { + let entry = Self.entry(provider: "openai", model: "gpt-5.4", cached: true) + let bare = CostUsageCustomPricing.Rates(input: 3, output: 4, cacheRead: 1, cacheWrite: 2) + let qualified = CostUsageCustomPricing.Rates(input: 9, output: 9, cacheRead: 9, cacheWrite: 9) + let cases: [([String: CostUsageCustomPricing.Rates], Double)] = [ + (["gpt-5.4": bare], 0.000030), + (["gpt-5.4": bare, "openai/gpt-5.4": qualified], 0.000030), + (["openai/gpt-5.4": qualified], 0.000108), + (["gpt-5.4": .init(input: 0, output: 0, cacheRead: 0, cacheWrite: 0)], 0), + ] + for (entries, expected) in cases { + let snapshot = Self.snapshot(entry, overlay: .init(entries: entries, fingerprint: "overlay")) + let cost = try #require(snapshot.last30DaysCostUSD) + #expect(abs(cost - expected) < 0.000000000001) + } + } + + @Test func `incomplete bare application override stays unknown ahead of qualified and catalog prices`() { + let snapshot = Self.snapshot( + Self.entry(provider: "openai", model: "gpt-5.4"), + overlay: .init(entries: [ + "gpt-5.4": .init(input: 3), + "openai/gpt-5.4": .init(input: 9, output: 9), + ], fingerprint: "missing-output")) + #expect(snapshot.last30DaysTokens == 5) + #expect(snapshot.last30DaysCostUSD == nil) + } + + @Test func `snapshot custom prices keep precedence over the application overlay`() throws { + let snapshot = Self.snapshot( + Self.entry(provider: "openai", model: "gpt-5.4"), + pricing: .init(entries: ["gpt-5.4": .init(input: 2, output: 2)], fingerprint: "snapshot"), + overlay: .init(entries: ["gpt-5.4": .init(input: 9, output: 9)], fingerprint: "application")) + let cost = try #require(snapshot.last30DaysCostUSD) + #expect(abs(cost - 0.000010) < 0.000000000001) + } + + @Test func `standalone xai custom estimates survive cache loading without subscription attribution`() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let log = root.appendingPathComponent("usage.jsonl") + let cache = root.appendingPathComponent("cache") + let pricingFile = root.appendingPathComponent("custom-pricing.json") + let model = "grok-fictional-priced" + let timestamp = Int(Self.now.timeIntervalSince1970 * 1000) + for source in ["", "xai-api-key"] { + let attempts = source.isEmpty ? "[]" : """ + [{"ordinal":1,"provider":"xai","model":"\(model)","sendCount":1,\ + "credentialSource":"\(source)","usageStatus":"reported",\ + "usage":{"inputTokens":3,"outputTokens":2,"totalTokens":5}}] + """ + try """ + {"requestId":"standalone","timestamp":\(timestamp),"provider":"xai","model":"\(model)",\ + "usageStatus":"reported","usage":{"inputTokens":3,"outputTokens":2,"totalTokens":5},\ + "attempts":\(attempts)} + + """.write(to: log, atomically: true, encoding: .utf8) + for key in [model, "xai/\(model)"] { + for rate in [0.0, 2.0] { + try """ + {"\(key)":{"input":\(rate),"output":\(rate)}} + """.write(to: pricingFile, atomically: true, encoding: .utf8) + let pricing = CostUsageCustomPricing.load(fileURL: pricingFile) + let store = OpenCodexUsageStore(cacheRoot: cache) + let entries = try store.loadEntries(logURL: log) + let entry = try #require(entries.first) + #expect(entry.credentialSource == nil) + #expect(OpenCodexUsageFanOut.snapshotsBySubscription( + entries: entries, + now: Self.now, + historyDays: 7, + calendar: Self.calendar, + customPricing: pricing).isEmpty) + let loaded = try store.loadSnapshot( + logURL: log, + now: Self.now, + historyDays: 7, + calendar: Self.calendar, + customPricing: pricing) + let application = Self.snapshot(entry, overlay: pricing) + for snapshot in [loaded, application] { + let cost = try #require(snapshot.last30DaysCostUSD) + #expect(abs(cost - rate * 5 / 1_000_000) < 0.000000000001) + #expect(snapshot.costProvenance == .listPriceEstimate) + } + #expect(Self.snapshot(entry).last30DaysCostUSD == nil) + } + } + } + } + + private static func entry(provider: String, model: String, cached: Bool = false) -> OpenCodexUsageEntry { + OpenCodexUsageEntry( + requestID: "pricing", + timestamp: self.now, + provider: provider, + model: model, + usageStatus: .reported, + usage: OpenCodexTokenUsage( + inputTokens: cached ? 10 : 3, + outputTokens: 2, + cacheReadInputTokens: cached ? 3 : nil, + cacheCreationInputTokens: cached ? 2 : nil, + totalTokens: cached ? 12 : 5), + totalTokens: cached ? 12 : 5) + } + + private static func snapshot( + _ entry: OpenCodexUsageEntry, + pricing: CostUsageCustomPricing = .empty, + overlay: CostUsageCustomPricing = .empty) -> CostUsageTokenSnapshot + { + OpenCodexUsageAggregator.snapshot( + entries: [entry], + now: self.now, + historyDays: 7, + calendar: self.calendar, + customPricing: pricing, + modelsDevCatalog: ModelsDevCatalog(providers: [:]), + customPricingOverlay: overlay) + } +} diff --git a/docs/grok.md b/docs/grok.md index 6a12cfc43a..e387db249f 100644 --- a/docs/grok.md +++ b/docs/grok.md @@ -197,6 +197,12 @@ Missing token classes or unknown prices retain tokens without inventing a dollar The Grok menu continues to use native CLI logs; this opt-in integration is for **Usage & Spend**. Neither source is a subscription invoice. +Standalone OpenCodex reports preserve explicit custom prices for xAI records, including +API-key and historic usage. Those user-configured estimates do not establish Grok +subscription attribution. Without a matching override, raw xAI records remain token-only. +Application price overrides retain bare-model key precedence before provider-qualified +keys; missing rate fields remain unknown rather than falling back to catalog prices. + ## JSON-RPC contract - Transport: stdin/stdout, newline-delimited JSON-RPC 2.0 (no Content-Length framing). From 6d3d5af1bb28aeaeb2bdb13cfcc70cdd4d0b2cc7 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Fri, 11 Sep 2026 22:25:04 -0700 Subject: [PATCH 33/34] Guard native Grok token decoding and accumulation --- .../Grok/GrokLocalSessionScanner.swift | 179 +++++++++++------- .../GrokNativeOverflowTests.swift | 103 ++++++++++ 2 files changed, 212 insertions(+), 70 deletions(-) create mode 100644 Tests/CodexBarTests/GrokNativeOverflowTests.swift diff --git a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift index 54b568fc25..3b4ba43c55 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift @@ -1,14 +1,15 @@ +import CoreFoundation import Foundation /// One local-calendar day of Grok session-token activity. public struct GrokLocalDailyBucket: Sendable, Equatable { public let date: String - public let inputTokens: Int - public let cacheReadTokens: Int - public let cacheCreationTokens: Int - public let outputTokens: Int - public let reasoningTokens: Int - public let totalTokens: Int + public let inputTokens: Int? + public let cacheReadTokens: Int? + public let cacheCreationTokens: Int? + public let outputTokens: Int? + public let reasoningTokens: Int? + public let totalTokens: Int? public let sessionCount: Int public let requestCount: Int public let costUSD: Double? @@ -19,12 +20,12 @@ public struct GrokLocalDailyBucket: Sendable, Equatable { public init( date: String, - inputTokens: Int = 0, - cacheReadTokens: Int = 0, - cacheCreationTokens: Int = 0, - outputTokens: Int = 0, - reasoningTokens: Int = 0, - totalTokens: Int, + inputTokens: Int? = 0, + cacheReadTokens: Int? = 0, + cacheCreationTokens: Int? = 0, + outputTokens: Int? = 0, + reasoningTokens: Int? = 0, + totalTokens: Int?, sessionCount: Int, requestCount: Int? = nil, costUSD: Double? = nil, @@ -54,7 +55,7 @@ public struct GrokLocalDailyBucket: Sendable, Equatable { /// `signals.json` is metadata-only fallback when a session has no completed turns. public struct GrokLocalSessionSummary: Sendable { public let sessionCount: Int - public let totalTokens: Int + public let totalTokens: Int? public let lastSessionAt: Date? public let primaryModel: String? public let models: [String] @@ -66,7 +67,7 @@ public struct GrokLocalSessionSummary: Sendable { public init( sessionCount: Int, - totalTokens: Int, + totalTokens: Int?, lastSessionAt: Date?, primaryModel: String?, models: [String], @@ -199,12 +200,12 @@ struct GrokLocalSessionScanLimits: Sendable, Equatable { } private struct GrokParsedTokenUsage: Sendable { - let inputTokens: Int - let outputTokens: Int - let totalTokens: Int - let cachedReadTokens: Int - let cacheCreationTokens: Int - let reasoningTokens: Int + let inputTokens: Int? + let outputTokens: Int? + let totalTokens: Int? + let cachedReadTokens: Int? + let cacheCreationTokens: Int? + let reasoningTokens: Int? let modelCalls: Int? /// Spend the Grok CLI recorded for this usage, in ticks. `nil` when the record omits it or reports 0. let costUsdTicks: Int? @@ -386,12 +387,12 @@ public enum GrokLocalSessionScanner { } private struct MutableModelBreakdown { - var inputTokens = 0 - var cacheReadTokens = 0 - var cacheCreationTokens = 0 - var outputTokens = 0 - var reasoningTokens = 0 - var totalTokens = 0 + var inputTokens: Int? = 0 + var cacheReadTokens: Int? = 0 + var cacheCreationTokens: Int? = 0 + var outputTokens: Int? = 0 + var reasoningTokens: Int? = 0 + var totalTokens: Int? = 0 var requestCount = 0 var costUSD = 0.0 var hasPricedCost = false @@ -399,12 +400,12 @@ public enum GrokLocalSessionScanner { } private struct MutableDailyBucket { - var inputTokens = 0 - var cacheReadTokens = 0 - var cacheCreationTokens = 0 - var outputTokens = 0 - var reasoningTokens = 0 - var totalTokens = 0 + var inputTokens: Int? = 0 + var cacheReadTokens: Int? = 0 + var cacheCreationTokens: Int? = 0 + var outputTokens: Int? = 0 + var reasoningTokens: Int? = 0 + var totalTokens: Int? = 0 var requestCount = 0 var sessionIDs: Set = [] var modelCounts: [String: Int] = [:] @@ -695,9 +696,20 @@ public enum GrokLocalSessionScanner { let buckets = aggregation.daily.keys.sorted().map { day in self.finalize(day: day, bucket: aggregation.daily[day] ?? MutableDailyBucket()) } + let totalTokens = buckets.reduce(Int?(0)) { self.addCounts($0, $1.totalTokens) } + historyCoverageIsEstablished = historyCoverageIsEstablished && totalTokens != nil + && buckets.allSatisfy { bucket in + [ + bucket.inputTokens, + bucket.outputTokens, + bucket.cacheReadTokens, + bucket.cacheCreationTokens, + bucket.reasoningTokens, + ].allSatisfy { $0 != nil } + } return GrokLocalSessionSummary( sessionCount: sessionCount, - totalTokens: buckets.reduce(0) { $0 + $1.totalTokens }, + totalTokens: totalTokens, lastSessionAt: lastSessionAt, primaryModel: sortedModels.first, models: sortedModels, @@ -940,23 +952,36 @@ public enum GrokLocalSessionScanner { } private static func tokenUsage(from object: [String: Any]) -> GrokParsedTokenUsage { - let inputTokens = max(0, self.integer(object["inputTokens"]) ?? 0) - let outputTokens = max(0, self.integer(object["outputTokens"]) ?? 0) - let computedTotalTokens = inputTokens + outputTokens - let reportedTotalTokens = max(0, self.integer(object["totalTokens"]) ?? 0) + let inputTokens = self.tokenCount(object["inputTokens"]) + let outputTokens = self.tokenCount(object["outputTokens"]) + let reportedTotalTokens = self.tokenCount(object["totalTokens"]) + let totalTokens = reportedTotalTokens == 0 + ? self.addCounts(inputTokens, outputTokens) : reportedTotalTokens return GrokParsedTokenUsage( inputTokens: inputTokens, outputTokens: outputTokens, - totalTokens: reportedTotalTokens > 0 || computedTotalTokens == 0 - ? reportedTotalTokens - : computedTotalTokens, - cachedReadTokens: max(0, self.integer(object["cachedReadTokens"]) ?? 0), - cacheCreationTokens: max(0, self.integer(object["cacheCreationTokens"]) ?? 0), - reasoningTokens: max(0, self.integer(object["reasoningTokens"]) ?? 0), + totalTokens: totalTokens, + cachedReadTokens: self.tokenCount(object["cachedReadTokens"]), + cacheCreationTokens: self.tokenCount(object["cacheCreationTokens"]), + reasoningTokens: self.tokenCount(object["reasoningTokens"]), modelCalls: self.integer(object["modelCalls"]), costUsdTicks: self.recordedCostTicks(object["costUsdTicks"])) } + /// Absent native token classes are zero; malformed or unrepresentable values remain unknown. + private static func tokenCount(_ value: Any?) -> Int? { + guard let value else { return 0 } + guard let count = self.integer(value), count >= 0 else { return nil } + return count + } + + /// Unknown values stay unknown after later valid contributions at every aggregation level. + private static func addCounts(_ lhs: Int?, _ rhs: Int?) -> Int? { + guard let lhs, let rhs else { return nil } + let (sum, overflow) = lhs.addingReportingOverflow(rhs) + return overflow ? nil : sum + } + /// `costUsdTicks` is the spend the CLI recorded for a turn, already carrying its price tier and any /// promotional rate. A record that omits the field, or reports 0 as a small share of turns do, has no /// recorded spend; those entries fall back to the public card. @@ -966,9 +991,12 @@ public enum GrokLocalSessionScanner { } private static func integer(_ value: Any?) -> Int? { - if let value = value as? Int { return value } - if let value = value as? NSNumber { return value.intValue } - return nil + guard let number = value as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID() + else { return nil } + // NSNumber's Int bridge may clamp oversized values. Preserve exact integer payloads first. + if let integer = Int(number.stringValue) { return integer } + return Int(exactly: number.doubleValue) } private static func readSignalsMetadata(at url: URL, maximumBytes: Int) -> [String]? { @@ -1009,12 +1037,12 @@ extension GrokLocalSessionScanner { { guard let day = self.dayKey(for: turn.timestamp, calendar: calendar) else { return } var bucket = aggregation.daily[day] ?? MutableDailyBucket() - bucket.inputTokens += turn.usage.inputTokens - bucket.cacheReadTokens += turn.usage.cachedReadTokens - bucket.cacheCreationTokens += turn.usage.cacheCreationTokens - bucket.outputTokens += turn.usage.outputTokens - bucket.reasoningTokens += turn.usage.reasoningTokens - bucket.totalTokens += turn.usage.totalTokens + bucket.inputTokens = self.addCounts(bucket.inputTokens, turn.usage.inputTokens) + bucket.cacheReadTokens = self.addCounts(bucket.cacheReadTokens, turn.usage.cachedReadTokens) + bucket.cacheCreationTokens = self.addCounts(bucket.cacheCreationTokens, turn.usage.cacheCreationTokens) + bucket.outputTokens = self.addCounts(bucket.outputTokens, turn.usage.outputTokens) + bucket.reasoningTokens = self.addCounts(bucket.reasoningTokens, turn.usage.reasoningTokens) + bucket.totalTokens = self.addCounts(bucket.totalTokens, turn.usage.totalTokens) bucket.sessionIDs.insert(sessionPath) // The outer tick is the authoritative turn total, including when model usage is populated. @@ -1038,12 +1066,12 @@ extension GrokLocalSessionScanner { aggregation.modelCounts[sku, default: 0] += requests bucket.modelCounts[sku, default: 0] += requests var breakdown = bucket.modelBreakdowns[sku] ?? MutableModelBreakdown() - breakdown.inputTokens += usage.inputTokens - breakdown.cacheReadTokens += usage.cachedReadTokens - breakdown.cacheCreationTokens += usage.cacheCreationTokens - breakdown.outputTokens += usage.outputTokens - breakdown.reasoningTokens += usage.reasoningTokens - breakdown.totalTokens += usage.totalTokens + breakdown.inputTokens = self.addCounts(breakdown.inputTokens, usage.inputTokens) + breakdown.cacheReadTokens = self.addCounts(breakdown.cacheReadTokens, usage.cachedReadTokens) + breakdown.cacheCreationTokens = self.addCounts(breakdown.cacheCreationTokens, usage.cacheCreationTokens) + breakdown.outputTokens = self.addCounts(breakdown.outputTokens, usage.outputTokens) + breakdown.reasoningTokens = self.addCounts(breakdown.reasoningTokens, usage.reasoningTokens) + breakdown.totalTokens = self.addCounts(breakdown.totalTokens, usage.totalTokens) breakdown.requestCount += requests if recordedTurnCost != nil { @@ -1100,6 +1128,11 @@ extension GrokLocalSessionScanner { pricingDate: Date, pricing: PricingContext) -> Double? { + guard let inputTokens = usage.inputTokens, + let outputTokens = usage.outputTokens, + let cachedReadTokens = usage.cachedReadTokens, + let cacheCreationTokens = usage.cacheCreationTokens + else { return nil } let model = "xai/\(sku)" guard let resolvedPricing = CostUsagePricing.resolvedCodexPricing( model: model, @@ -1110,16 +1143,16 @@ extension GrokLocalSessionScanner { guard let callCount = self.validatedModelCallCount(for: usage) else { if let threshold = resolvedPricing.thresholdTokens, - usage.inputTokens > threshold + inputTokens > threshold { return nil } return CostUsagePricing.codexCostUSD( pricing: resolvedPricing, - inputTokens: usage.inputTokens, - cachedInputTokens: usage.cachedReadTokens, - cacheWriteInputTokens: usage.cacheCreationTokens, - outputTokens: usage.outputTokens) + inputTokens: inputTokens, + cachedInputTokens: cachedReadTokens, + cacheWriteInputTokens: cacheCreationTokens, + outputTokens: outputTokens) } // Without recorded turn or model spend, even splitting approximates public list prices. @@ -1153,8 +1186,9 @@ extension GrokLocalSessionScanner { private static func validatedModelCallCount(for usage: GrokParsedTokenUsage) -> Int? { guard let modelCalls = usage.modelCalls, + let inputTokens = usage.inputTokens, modelCalls > 0, - modelCalls <= usage.inputTokens, + modelCalls <= inputTokens, modelCalls <= self.maximumValidatedModelCalls else { return nil } return modelCalls @@ -1169,8 +1203,13 @@ extension GrokLocalSessionScanner { callCount: Int, pricing: CostUsagePricing.CodexPricing) -> [SyntheticCallGroup] { - let largerInputCallCount = usage.inputTokens % callCount - let baseInput = usage.inputTokens / callCount + guard let inputTokens = usage.inputTokens, + let outputTokens = usage.outputTokens, + let cachedReadTokens = usage.cachedReadTokens, + let cacheCreationTokens = usage.cacheCreationTokens + else { return [] } + let largerInputCallCount = inputTokens % callCount + let baseInput = inputTokens / callCount let threshold = pricing.thresholdTokens var ranges: [(range: Range, isLongContext: Bool)] = [] if largerInputCallCount > 0 { @@ -1185,9 +1224,9 @@ extension GrokLocalSessionScanner { } return ranges.map { group in let effectiveInput = self.effectiveInputTotals( - inputTokens: usage.inputTokens, - cachedReadTokens: usage.cachedReadTokens, - cacheCreationTokens: usage.cacheCreationTokens, + inputTokens: inputTokens, + cachedReadTokens: cachedReadTokens, + cacheCreationTokens: cacheCreationTokens, callCount: callCount, range: group.range) return SyntheticCallGroup( @@ -1195,7 +1234,7 @@ extension GrokLocalSessionScanner { cachedReadTokens: effectiveInput.cachedRead, cacheCreationTokens: effectiveInput.cacheCreation, outputTokens: self.distributedTotal( - usage.outputTokens, + outputTokens, count: callCount, range: group.range), isLongContext: group.isLongContext) diff --git a/Tests/CodexBarTests/GrokNativeOverflowTests.swift b/Tests/CodexBarTests/GrokNativeOverflowTests.swift new file mode 100644 index 0000000000..eca9511c3d --- /dev/null +++ b/Tests/CodexBarTests/GrokNativeOverflowTests.swift @@ -0,0 +1,103 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct GrokNativeOverflowTests: GrokLocalSessionScannerTestSupport { + @Test(arguments: [false, true]) + func `overflowing derived totals preserve explicit totals and token classes`(_ hasExplicitTotal: Bool) throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let now = try self.localDate(day: 20, hour: 15) + var usage: [String: Any] = ["inputTokens": Int.max, "outputTokens": 1, "costUsdTicks": 100] + if hasExplicitTotal { usage["totalTokens"] = 7 } + usage["modelUsage"] = ["grok-4.6-build": usage] + try self.writeUpdates( + [self.turn(timestamp: now, usage: usage)], + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: now) + let summary = try self.summarize(fixture: fixture, now: now) + let expected: Int? = hasExplicitTotal ? 7 : nil + #expect(summary.totalTokens == expected) + #expect(summary.historyCoverageIsEstablished == hasExplicitTotal) + let day = try #require(summary.daily.first) + #expect(day.inputTokens == Int.max) + #expect(day.outputTokens == 1) + #expect(day.totalTokens == expected) + #expect(day.modelBreakdowns.first?.totalTokens == expected) + let snapshot = try #require(summary.toCostUsageTokenSnapshot(historyDays: 7)) + #expect(snapshot.last30DaysTokens == expected) + #expect(snapshot.sessionTokens == expected) + #expect(snapshot.last30DaysCostUSD == 100 / GrokLocalSessionScanner.costUsdTicksPerUSD) + #expect(snapshot.costProvenance == .vendorMetered) + } + + @Test(arguments: [false, true]) + func `native bucket model and window sums keep overflow unknown after later turns`(_ separateDays: Bool) throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let now = try self.localDate(day: 20, hour: 15) + let rows = [Int.max, 1, 5].enumerated().map { index, count in + var usage: [String: Any] = [ + "inputTokens": count, "outputTokens": 2, "totalTokens": count, + "cachedReadTokens": count, "cacheCreationTokens": count, "reasoningTokens": count, + "costUsdTicks": 100, + ] + usage["modelUsage"] = ["grok-4.6-build": usage] + let offset = separateDays ? Double(index - 2) * 86400 : 0 + return self.turn(timestamp: now.addingTimeInterval(offset), usage: usage) + } + try self.writeUpdates( + rows, + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: now) + for _ in 0..<2 { + // The second scan consumes the parsed cache and must retain the same unknown totals. + let summary = try self.summarize(fixture: fixture, now: now) + #expect(summary.totalTokens == nil) + #expect(!summary.historyCoverageIsEstablished) + let snapshot = try #require(summary.toCostUsageTokenSnapshot(historyDays: 7)) + #expect(snapshot.last30DaysTokens == nil) + #expect(snapshot.daily.count == (separateDays ? 3 : 1)) + #expect(snapshot.last30DaysRequests == 3) + if !separateDays { + let day = try #require(snapshot.daily.first) + #expect(day.totalTokens == nil) + #expect(day.inputTokens == nil) + #expect(day.cacheReadTokens == nil) + #expect(day.cacheCreationTokens == nil) + #expect(day.reasoningTokens == nil) + #expect(day.outputTokens == 6) + #expect(day.modelBreakdowns?.first?.totalTokens == nil) + #expect(day.modelBreakdowns?.first?.inputTokens == nil) + #expect(day.modelBreakdowns?.first?.outputTokens == 6) + } + } + } + + @Test(arguments: ["true", "1.5", "1e40", "9223372036854775808", "-1"]) + func `invalid native token numbers remain unknown without erasing valid fields`(_ literal: String) throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let now = try self.localDate(day: 20, hour: 15) + let raw = """ + {"timestamp":\(Int(now.timeIntervalSince1970)),"params":{"update":{"sessionUpdate":"turn_completed",\ + "usage":{"inputTokens":\(literal),"outputTokens":2,"costUsdTicks":100,\ + "modelUsage":{"grok-4.6-build":{"inputTokens":\(literal),"outputTokens":2,"costUsdTicks":100}}}}}} + """ + try self.writeUpdates( + [], + rawLines: [raw], + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: now) + let summary = try self.summarize(fixture: fixture, now: now) + let day = try #require(summary.daily.first) + #expect(summary.totalTokens == nil) + #expect(!summary.historyCoverageIsEstablished) + #expect(day.inputTokens == nil) + #expect(day.outputTokens == 2) + #expect(day.modelBreakdowns.first?.inputTokens == nil) + #expect(day.modelBreakdowns.first?.outputTokens == 2) + #expect(day.costUSD == 100 / GrokLocalSessionScanner.costUsdTicksPerUSD) + } +} From a41736133e7a2d922db7bbc5b0e18e7e729e855d Mon Sep 17 00:00:00 2001 From: olddonkey Date: Fri, 11 Sep 2026 22:35:22 -0700 Subject: [PATCH 34/34] Preserve unknown Grok totals through menu projections --- Sources/CodexBar/MenuCardView+Costs.swift | 11 +--- .../Grok/UsageStore+GrokLocalSessions.swift | 6 +- .../CodexBar/UsageStore+WidgetSnapshot.swift | 11 +--- Sources/CodexBarCore/CostUsageModels.swift | 25 ++++---- .../GrokTokenSnapshotProjectionTests.swift | 60 +++++++++++++++++++ .../ProviderArchitectureGatekeeperTests.swift | 20 +++---- 6 files changed, 88 insertions(+), 45 deletions(-) diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index 4add51f8a5..57ba1ec277 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -219,15 +219,8 @@ extension UsageMenuCardView.Model { preferredCurrency: preferredCurrencyCode, providerCurrency: snapshot.currencyCode) } ?? "—" - let fallbackTokens: Int? = { - var sum = 0 - for t in snapshot.daily.compactMap(\.totalTokens) { - let (res, of) = sum.addingReportingOverflow(t) - if of { return nil } - sum = res - } - return sum > 0 ? sum : nil - }() + let fallbackTokens = CostUsageDailyReport.completeCountSum(snapshot.daily.map(\.totalTokens)) + .flatMap { $0 > 0 ? $0 : nil } let monthTokensValue = snapshot.last30DaysTokens ?? fallbackTokens let monthTokens = monthTokensValue.map { UsageFormatter.tokenCountString($0) } let windowLabel = if let historyLabel = snapshot.historyLabel { diff --git a/Sources/CodexBar/Providers/Grok/UsageStore+GrokLocalSessions.swift b/Sources/CodexBar/Providers/Grok/UsageStore+GrokLocalSessions.swift index 6af05f098b..41f81dc08a 100644 --- a/Sources/CodexBar/Providers/Grok/UsageStore+GrokLocalSessions.swift +++ b/Sources/CodexBar/Providers/Grok/UsageStore+GrokLocalSessions.swift @@ -19,8 +19,6 @@ extension UsageStore { else { return nil } let daily = published.daily.filter { $0.date >= firstDay && $0.date <= lastDay } guard !daily.isEmpty else { return nil } - let tokens = daily.compactMap(\.totalTokens) - let requests = daily.compactMap(\.requestCount) // Tokens and requests are recomputed from the retained days, so the cost has to be too. Copying the // published total would render the full 365-day amount beside a 30-day token count. let costs = daily.compactMap(\.costUSD) @@ -29,9 +27,9 @@ extension UsageStore { sessionTokens: published.sessionTokens, sessionCostUSD: published.sessionCostUSD, sessionRequests: published.sessionRequests, - last30DaysTokens: tokens.isEmpty ? nil : tokens.reduce(0, +), + last30DaysTokens: CostUsageDailyReport.completeCountSum(daily.map(\.totalTokens)), last30DaysCostUSD: costs.isEmpty ? nil : costs.reduce(0, +), - last30DaysRequests: requests.isEmpty ? nil : requests.reduce(0, +), + last30DaysRequests: CostUsageDailyReport.completeCountSum(daily.map(\.requestCount)), currencyCode: published.currencyCode, historyDays: days, historyCoverageIsEstablished: published.historyCoverageIsEstablished && published.historyDays >= days, diff --git a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift index b92e755b05..15afc90f97 100644 --- a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift +++ b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift @@ -360,15 +360,8 @@ extension UsageStore { provider: UsageProvider) -> WidgetSnapshot.TokenUsageSummary? { guard let snapshot else { return nil } - let fallbackTokens: Int? = { - var sum = 0 - for t in snapshot.daily.compactMap(\.totalTokens) { - let (res, of) = sum.addingReportingOverflow(t) - if of { return nil } - sum = res - } - return sum > 0 ? sum : nil - }() + let fallbackTokens = CostUsageDailyReport.completeCountSum(snapshot.daily.map(\.totalTokens)) + .flatMap { $0 > 0 ? $0 : nil } let monthTokensValue = snapshot.last30DaysTokens ?? fallbackTokens let sessionLabel = if provider == .bedrock || provider == .mistral { "Latest billing day" diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index 95bc717d64..d4ca135901 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -217,10 +217,9 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { } else { nil } - let requests = entries.compactMap(\.requestCount) let allEntriesCarryRequests = !entries.isEmpty && entries.allSatisfy { $0.requestCount != nil } let totalRequests: Int? = if allEntriesCarryRequests { - requests.reduce(0, +) + CostUsageDailyReport.completeCountSum(entries.map(\.requestCount)) } else if self.historyCoverageIsEstablished, entries.isEmpty { 0 } else { @@ -276,7 +275,6 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { return dayKey >= startKey && dayKey <= endKey } let costs = entries.compactMap(\.costUSD) - let tokens = entries.compactMap(\.totalTokens) let requests = entries.compactMap(\.requestCount) var mix = CostUsageTokenMix() var coverage = CostUsageCoverageAccumulator() @@ -286,16 +284,7 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { } let coversFullHistory = days >= self.historyDays let windowMetered = coversFullHistory ? self.meteredCostUSD : nil - let totalTokens: Int? = { - guard !tokens.isEmpty else { return nil } - var sum = 0 - for t in tokens { - let (res, of) = sum.addingReportingOverflow(t) - if of { return nil } - sum = res - } - return sum - }() + let totalTokens = CostUsageDailyReport.completeCountSum(entries.map(\.totalTokens)) let totalRequests: Int? = { guard !requests.isEmpty else { return nil } var sum = 0 @@ -871,6 +860,16 @@ extension CostUsageDailyReport { } } + /// Counts are complete only when every contribution is valid and their sum is representable. + package static func completeCountSum(_ values: some Sequence) -> Int? { + var total = OptionalCountAccumulator() + for value in values { + guard let value, value >= 0 else { return nil } + total.add(value) + } + return total.value + } + struct OptionalCountAccumulator { private(set) var value: Int? private var overflowed = false diff --git a/Tests/CodexBarTests/GrokTokenSnapshotProjectionTests.swift b/Tests/CodexBarTests/GrokTokenSnapshotProjectionTests.swift index 16dac246a3..23eca4b1a7 100644 --- a/Tests/CodexBarTests/GrokTokenSnapshotProjectionTests.swift +++ b/Tests/CodexBarTests/GrokTokenSnapshotProjectionTests.swift @@ -104,6 +104,66 @@ struct GrokTokenSnapshotProjectionTests: GrokLocalSessionScannerTestSupport { #expect(projected.last30DaysTokens == 85) } + @Test(arguments: [false, true], [false, true]) + func `native overflow stays unknown through remote and fallback menu projections`( + hasRemoteSnapshot: Bool, + hasUnknownDay: Bool) throws + { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let now = try self.localDate(day: 20, hour: 15) + let yesterday = try #require(Calendar.current.date(byAdding: .day, value: -1, to: now)) + var rows: [[String: Any]] = [self.turn(timestamp: yesterday, usage: [ + "inputTokens": Int.max, "outputTokens": 0, "totalTokens": Int.max, "costUsdTicks": 100, + ])] + if hasUnknownDay { + rows.append(self.turn(timestamp: yesterday, usage: [ + "inputTokens": 1, "outputTokens": 0, "totalTokens": 1, "costUsdTicks": 100, + ])) + } + rows.append(self.turn(timestamp: now, usage: [ + "inputTokens": 1, "outputTokens": 0, "totalTokens": 1, "costUsdTicks": 100, + ])) + try self.writeUpdates( + rows, + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: now) + let summary = try self.summarize(fixture: fixture, now: now) + let published = try #require(summary.toCostUsageTokenSnapshot(historyDays: 365)) + #expect(published.last30DaysTokens == nil) + let store = Self.makeStore(environment: [:]) + store.publishTokenSnapshot(published, for: .grok) + let remote = hasRemoteSnapshot + ? UsageSnapshot(primary: nil, secondary: nil, costUsage: published, updatedAt: now) : nil + let selected = try #require(store.tokenSnapshotForLiveProviderConsumer( + fromProviderSnapshot: remote, + provider: .grok, + historyDays: 30)) + #expect(selected.last30DaysTokens == nil) + #expect(!selected.historyCoverageIsEstablished) + #expect(selected.sessionTokens == 1) + #expect(selected.summary(forLastDays: 30).totalTokens == nil) + let menu = try #require(UsageMenuCardView.Model.tokenUsageSection( + provider: .grok, + enabled: true, + comparisonPeriodsEnabled: false, + snapshot: selected, + error: nil)) + #expect(!menu.monthLine.contains("tokens")) + let widget = try #require(UsageStore.widgetTokenUsageSummary(from: selected, provider: .grok)) + #expect(widget.last30DaysTokens == nil) + let dashboard = SpendDashboardModel.build( + inputs: [.init(provider: .grok, displayName: "Grok", snapshot: selected)], + requestedDays: 30, + now: now) + let group = try #require(dashboard.groups.first) + #expect(group.totalTokens == nil) + // Excluding the affected day restores the valid one-day value on both consumer paths. + let today = try #require(store.tokenSnapshotForLiveProviderConsumer( + fromProviderSnapshot: remote, provider: .grok, historyDays: 1)) + #expect(today.last30DaysTokens == 1) + } + private static func makeStore(environment: [String: String]) -> UsageStore { let suite = "GrokTokenSnapshotProjectionTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index ff4fad7d99..3f7efa4bb0 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1833,7 +1833,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+Costs.swift", - line: 235, + line: 228, anchor: "} else if provider == .mistral,", expectedProviderIDs: ["mistral"], expectedReferenceCount: 1, @@ -1841,7 +1841,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+Costs.swift", - line: 494, + line: 487, anchor: "if style == .claude {", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3314,7 +3314,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 373, + line: 366, anchor: "let sessionLabel = if provider == .bedrock || provider == .mistral {", expectedProviderIDs: ["bedrock", "codex", "mistral"], expectedReferenceCount: 4, @@ -3322,7 +3322,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 443, + line: 436, anchor: "if provider == .codex {", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3330,7 +3330,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 462, + line: 455, anchor: "if provider == .claude,", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3338,7 +3338,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 403, + line: 396, anchor: "if provider == .cursor, snapshot.detailRow(label: \"Request quota\") != nil {", expectedProviderIDs: ["alibabatokenplan", "amp", "crof", "cursor", "doubao", "grok", "ollama"], expectedReferenceCount: 7, @@ -3354,7 +3354,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 475, + line: 468, anchor: "if provider == .antigravity,", expectedProviderIDs: ["alibabatokenplan", "amp", "antigravity"], expectedReferenceCount: 4, @@ -3362,7 +3362,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 517, + line: 510, anchor: "if provider == .cursor {", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3370,7 +3370,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "Cursor Grok Bot weekly included usage is a named extraRateWindow on the shared widget projection."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 530, + line: 523, anchor: "if provider == .claude, self.settings.claudeModelScopedWeeklyUsageVisible {", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3378,7 +3378,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "Claude's opt-in widget projection adds provider-owned model-scoped weekly quota rows."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 544, + line: 537, anchor: "if provider == .kimi {", expectedProviderIDs: ["kimi"], expectedReferenceCount: 1,