From 70d2311281dead5f03ec09f7066837ff32024d0e Mon Sep 17 00:00:00 2001 From: olddonkey Date: Fri, 21 Aug 2026 20:02:37 -0700 Subject: [PATCH 01/11] 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+Refresh.swift | 13 +- Sources/CodexBar/UsageStore+TokenCost.swift | 72 +- Sources/CodexBar/UsageStore.swift | 27 +- Sources/CodexBarCore/CostUsageModels.swift | 66 ++ .../Generated/CodexParserHash.generated.swift | 2 +- .../Grok/GrokLocalSessionScanner.swift | 799 ++++++++++++++++-- .../Grok/GrokProviderDescriptor.swift | 12 +- .../Providers/Grok/GrokStatusProbe.swift | 6 +- .../Vendored/CostUsage/CostUsagePricing.swift | 11 + .../Vendored/CostUsage/CostUsageStore.swift | 1 + Tests/CodexBarTests/CostUsageStoreTests.swift | 3 +- .../GrokCostUsagePricingResolutionTests.swift | 59 ++ .../GrokCostUsagePricingTests.swift | 332 ++++++++ .../GrokLocalSessionScannerTestSupport.swift | 203 +++++ .../GrokLocalSessionScannerTests.swift | 464 ++++++++-- .../GrokXAISpendCatalogTests.swift | 2 + .../ProviderArchitectureGatekeeperTests.swift | 100 +-- 19 files changed, 1975 insertions(+), 246 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 eebad58a05..63e2d54640 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -282,15 +282,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) } @@ -789,13 +795,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+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index 201080b60d..9c84bf599c 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -1450,14 +1450,11 @@ extension UsageStore { // Provider-specific by design: local ~/.grok/sessions tokens remain readable // when the remote billing probe fails. if provider == .grok { - if let local = self.tokenSnapshot( - fromProviderSnapshot: nil, - provider: .grok, - historyDays: SpendDashboardSource.scanDays) - { - self.publishTokenSnapshot(local, for: provider) - } else { - self.clearTokenSnapshot(for: provider) + if self.tokenSnapshotPublicationForCurrentProviderConfig(for: provider) == nil { + Task { @MainActor [weak self] in + await self?.scanAndPublishGrokLocalTokenSnapshot( + historyDays: GrokLocalSessionScanner.maximumLookbackDays) + } } } else if Self.tokenCostRequiresProviderSnapshot(provider) { self.clearTokenSnapshot(for: provider) diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 94d9ebbf5b..eee43b2736 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -455,8 +455,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() @@ -475,13 +475,77 @@ extension UsageStore { case .xai: return snapshot.flatMap { XAICostUsageMapping.tokenSnapshot(from: $0, historyDays: windowDays) } case .grok: - return GrokLocalSessionScanner.summarize(lookbackDays: windowDays) - .toCostUsageTokenSnapshot(historyDays: windowDays) + return snapshot?.costUsage?.narrowed( + toHistoryDays: windowDays, + calendar: self.settings.costUsageBucketCalendar) default: return nil } } + @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 016e22dfc7..e187a042b8 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] = [:] var spendDashboardPublication = SpendDashboardPublication.empty @@ -275,6 +277,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?, @@ -963,6 +967,7 @@ final class UsageStore { self.codexPlanHistoryBackfillTask?.cancel() self.resetBoundaryRefreshTask?.cancel() self.planUtilizationHistoryLoadTask?.cancel() + self.grokLocalTokenScanTask?.cancel() } enum SessionQuotaWindowSource: String { @@ -971,23 +976,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 { @@ -1618,6 +1606,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 04aa966aac..75b2f8a508 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/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 31c83d4dfb..b40187a178 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 = "457945c40062d450" + static let value = "e47fab5975eb6346" } diff --git a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift index 7270b40298..b90a98930b 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,136 +79,748 @@ 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 - /// Walk `~/.grok/sessions///signals.json` and aggregate stats. + 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///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]] = [:] - - 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 aggregation = ScanAggregation() - 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) } + 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 dd3b05020d..139bcbc71f 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 @@ -336,7 +336,9 @@ struct GrokWebFetchStrategy: ProviderFetchStrategy { let subscriptionTier = try await resolveSettingsTier(authState) let identitySnapshot = GrokStatusProbe.identityOnlySnapshot( credentials: authState, - localSummary: GrokLocalSessionScanner.summarize(env: context.env), + localSummary: GrokLocalSessionScanner.summarize( + env: context.env, + lookbackDays: GrokLocalSessionScanner.maximumLookbackDays), cliVersion: GrokStatusProbe.detectVersion(env: context.env), subscriptionTier: subscriptionTier) return self.makeResult( @@ -363,7 +365,9 @@ struct GrokWebFetchStrategy: ProviderFetchStrategy { credentials: credentials, billing: nil, webBilling: enrichedBilling), - localSummary: GrokLocalSessionScanner.summarize(env: context.env), + localSummary: GrokLocalSessionScanner.summarize( + env: context.env, + lookbackDays: GrokLocalSessionScanner.maximumLookbackDays), cliVersion: GrokStatusProbe.detectVersion(env: context.env), updatedAt: Date(), subscriptionTier: subscriptionTier ?? enrichedBilling.subscriptionTier) diff --git a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift index a326a21976..0d3cd38966 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) } @@ -121,7 +121,9 @@ public struct GrokStatusProbe: Sendable { } // Local fallback summary always succeeds (empty if no sessions yet). - let localSummary = GrokLocalSessionScanner.summarize(env: env) + let localSummary = GrokLocalSessionScanner.summarize( + 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 fdaba8f0e9..7b1c001364 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -463,6 +463,7 @@ enum CostUsagePricing { "opencode", "opencode-free", "opencode-go", + "xai", ] private static let claudeModelsDevProviderID = "anthropic" @@ -489,6 +490,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/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift index 79a70f1016..678891bcfb 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift @@ -77,6 +77,7 @@ actor CostUsageStore { parserHash: CodexParserHash.value) static let cacheGeneration = "sqlite:\(CostUsageStore.schemaVersion)" static let compatiblePredecessorParserHashes: Set = [ + "3c984b655688593f", // xAI pricing lookup 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. diff --git a/Tests/CodexBarTests/CostUsageStoreTests.swift b/Tests/CodexBarTests/CostUsageStoreTests.swift index 0b64255256..cf491e0108 100644 --- a/Tests/CodexBarTests/CostUsageStoreTests.swift +++ b/Tests/CodexBarTests/CostUsageStoreTests.swift @@ -1005,6 +1005,7 @@ extension CostUsageStoreTests { let fixture = try StoreFixture() defer { fixture.remove() } #expect(CostUsageStore.compatiblePredecessorParserHashes == [ + "3c984b655688593f", "98da5914d2f6a9cd", "43609cc56f76a003", "b975eb705f905b9a", @@ -1012,7 +1013,7 @@ extension CostUsageStoreTests { "2d17f4981b78d07f", "3c984b655688593f", ]) - let predecessorHash = "43609cc56f76a003" + let predecessorHash = "3c984b655688593f" let predecessorVersion = CostUsageStore.combinedSchemaVersion( base: CostUsageStore.baseSchemaVersion, parserHash: predecessorHash) 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 61a629fc29..c17392b261 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -896,55 +896,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: 411, + line: 417, 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: 413, + line: 419, 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: 494, + line: 500, 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: 497, + line: 503, 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: 576, + line: 582, 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: 620, + line: 626, 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: 1557, + line: 1565, 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: 1586, + line: 1594, 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: 1603, + line: 1611, anchor: "if sourceID.hasPrefix(\"codex:\") { return .codex }", expectedProviderIDs: ["codex"], reason: "This publication projection maps stable Codex account source IDs back to their provider family."), @@ -1094,7 +1094,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: 1493, + line: 1490, 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."), @@ -1281,19 +1281,19 @@ struct ProviderArchitectureGatekeeperTests { reason: "Claude widget quota ownership uses the selected Claude account's isolated snapshot key."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore.swift", - line: 1066, + line: 1054, 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: 1168, + line: 1156, 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: 1172, + line: 1160, anchor: "provider: .claude,", expectedProviderIDs: ["claude"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), @@ -2320,7 +2320,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 609, + line: 615, anchor: "(providers.contains(.codex) && settings.codexLocalSessionCostLedgerEnabled)", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2355,12 +2355,21 @@ struct ProviderArchitectureGatekeeperTests { line: 281, 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", - line: 671, + line: 677, anchor: "if providers.contains(.codex) {", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2368,7 +2377,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: 699, + line: 705, anchor: "if providers.contains(.codex) {", expectedProviderIDs: ["codex"], expectedReferenceCount: 3, @@ -2376,7 +2385,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: 1630, + line: 1638, anchor: "guard input.provider == .codex,", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2776,7 +2785,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, @@ -2784,7 +2793,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, @@ -2929,7 +2938,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: 1494, + line: 1491, anchor: "cached.cacheKey == self.tokenAccountSnapshotCacheKey(provider: .claude, account: currentAccount)", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3155,7 +3164,7 @@ struct ProviderArchitectureGatekeeperTests { line: 461, anchor: "case .openai:", expectedProviderIDs: ["grok", "mistral", "openai", "opencodego", "openrouter", "xai"], - expectedReferenceCount: 12, + expectedReferenceCount: 7, expectedReferenceFingerprint: [ "openai@0", "mistral@2", @@ -3164,16 +3173,11 @@ struct ProviderArchitectureGatekeeperTests { "xai@14", "grok@16", "grok@28", - "mistral@28", - "openai@28", - "opencodego@28", - "openrouter@28", - "xai@28", ], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 531, + line: 595, anchor: "self.tokenFailureGates[.codex]?.reset()", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 2, @@ -3295,7 +3299,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 607, + line: 611, anchor: "self.metadata(for: .codex).browserCookieOrder ?? Browser.defaultImportOrder", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3303,7 +3307,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 659, + line: 663, anchor: "self.providerSpecs[provider]?.style ?? .codex", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3311,7 +3315,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 692, + line: 696, anchor: "guard provider != .codex else { return true }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3319,7 +3323,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1040, + line: 1028, anchor: "let claudeDebugConfiguration: ClaudeDebugLogConfiguration? = if provider == .claude {", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3327,7 +3331,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1063, + line: 1051, anchor: "let deepSeekHasTokenAccount = self.settings.selectedTokenAccount(for: .deepseek) != nil", expectedProviderIDs: ["deepseek"], expectedReferenceCount: 1, @@ -3335,7 +3339,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1120, + line: 1108, anchor: "case .amp:", expectedProviderIDs: ["amp", "deepseek", "notion", "ollama", "warp"], expectedReferenceCount: 7, @@ -3351,7 +3355,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1175, + line: 1163, anchor: "let claudeSettings = snapshot.claude ?? ProviderSettingsSnapshot.ClaudeProviderSettings(", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3692,21 +3696,21 @@ struct ProviderArchitectureGatekeeperTests { path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift", line: 452, 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: 487, + line: 488, 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", - line: 520, + line: 531, anchor: "if self.codex[trimmed] != nil {", expectedProviderIDs: ["codex"], expectedReferenceCount: 2, @@ -3714,7 +3718,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: 563, + line: 574, anchor: "if self.claude[base] != nil {", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3722,7 +3726,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: 596, + line: 607, anchor: "let bundled = lookup.pricing.providerID == self.codexModelsDevProviderID ? self.codex[key] : nil", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3730,7 +3734,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: 630, + line: 641, anchor: "guard let pricing = self.codex[key] else { return nil }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3738,7 +3742,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: 773, + line: 784, anchor: "guard let pricing = self.claude[key] else { return nil }", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, From fdd3175bc336ce30efea783447116b13e789f6ae Mon Sep 17 00:00:00 2001 From: olddonkey Date: Fri, 21 Aug 2026 21:15:25 -0700 Subject: [PATCH 02/11] 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 | 55 +++++-- .../OpenCodexRouteDispatcherTests.swift | 15 ++ .../OpenCodexUsageFanOutTests.swift | 146 +++++++++++++++++- docs/grok.md | 10 ++ 5 files changed, 214 insertions(+), 14 deletions(-) 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 05ab5645c0..64d1813861 100644 --- a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift @@ -59,7 +59,8 @@ enum OpenCodexUsageAggregator { now: Date, historyDays: Int, calendar: Calendar, - customPricing: CostUsageCustomPricing = .empty) -> CostUsageTokenSnapshot + customPricing: CostUsageCustomPricing = .empty, + modelsDevCatalog: ModelsDevCatalog? = nil) -> CostUsageTokenSnapshot { let days = max(1, min(365, historyDays)) let today = calendar.startOfDay(for: now) @@ -82,19 +83,31 @@ enum OpenCodexUsageAggregator { for entry in windowed { let dayKey = CostUsageLocalDay.key(from: entry.timestamp, calendar: calendar) var day = daysByKey[dayKey] ?? DayAccumulator() - Self.merge(entry, into: &day, customPricing: customPricing) + Self.merge( + entry, + into: &day, + customPricing: customPricing, + modelsDevCatalog: modelsDevCatalog) daysByKey[dayKey] = day let sessionID = entry.conversationID ?? entry.requestID var session = sessions[sessionID] ?? SessionAccumulator() session.lastActivity = max(session.lastActivity, entry.timestamp) session.requests += 1 - Self.merge(entry, into: &session, customPricing: customPricing) + Self.merge( + entry, + into: &session, + customPricing: customPricing, + modelsDevCatalog: modelsDevCatalog) sessions[sessionID] = session let hour = calendar.dateInterval(of: .hour, for: entry.timestamp)?.start ?? entry.timestamp var hourBucket = hoursByStart[hour] ?? HourAccumulator() - Self.merge(entry, into: &hourBucket, customPricing: customPricing) + Self.merge( + entry, + into: &hourBucket, + customPricing: customPricing, + modelsDevCatalog: modelsDevCatalog) hoursByStart[hour] = hourBucket } @@ -165,7 +178,8 @@ enum OpenCodexUsageAggregator { private static func merge( _ entry: OpenCodexUsageEntry, into day: inout DayAccumulator, - customPricing: CostUsageCustomPricing) + customPricing: CostUsageCustomPricing, + modelsDevCatalog: ModelsDevCatalog?) { let usage = entry.usage if let input = usage?.inputTokens { @@ -197,7 +211,10 @@ enum OpenCodexUsageAggregator { day.unmetered += entry.usageStatus == .unsupported ? 1 : 0 day.unpriced += entry.usageStatus == .unreported ? 1 : 0 - let cost = Self.listPriceUSD(entry: entry, customPricing: customPricing) + let cost = Self.listPriceUSD( + entry: entry, + customPricing: customPricing, + modelsDevCatalog: modelsDevCatalog) if let cost { day.cost += cost day.sawCost = true @@ -221,14 +238,18 @@ enum OpenCodexUsageAggregator { private static func merge( _ entry: OpenCodexUsageEntry, into session: inout SessionAccumulator, - customPricing: CostUsageCustomPricing) + customPricing: CostUsageCustomPricing, + modelsDevCatalog: ModelsDevCatalog?) { session.input = self.add(session.input, entry.usage?.inputTokens) session.output = self.add(session.output, entry.usage?.outputTokens) session.cacheRead = self.add(session.cacheRead, entry.usage?.cacheReadTokens) session.reasoning = self.add(session.reasoning, entry.usage?.reasoningOutputTokens) session.tokens = self.add(session.tokens, entry.resolvedTotalTokens) - let cost = self.listPriceUSD(entry: entry, customPricing: customPricing) + let cost = self.listPriceUSD( + entry: entry, + customPricing: customPricing, + modelsDevCatalog: modelsDevCatalog) session.cost = self.add(session.cost, cost) var model = session.models[entry.model] ?? ModelAccumulator() self.merge(entry, cost: cost, into: &model) @@ -238,13 +259,18 @@ enum OpenCodexUsageAggregator { private static func merge( _ entry: OpenCodexUsageEntry, into hour: inout HourAccumulator, - customPricing: CostUsageCustomPricing) + customPricing: CostUsageCustomPricing, + modelsDevCatalog: ModelsDevCatalog?) { if let tokens = entry.resolvedTotalTokens { hour.tokens += tokens hour.sawTokens = true } - if let cost = self.listPriceUSD(entry: entry, customPricing: customPricing) { + if let cost = self.listPriceUSD( + entry: entry, + customPricing: customPricing, + modelsDevCatalog: modelsDevCatalog) + { hour.cost += cost hour.sawCost = true } @@ -305,7 +331,8 @@ enum OpenCodexUsageAggregator { private static func listPriceUSD( entry: OpenCodexUsageEntry, - customPricing: CostUsageCustomPricing) -> Double? + customPricing: CostUsageCustomPricing, + modelsDevCatalog: ModelsDevCatalog?) -> Double? { guard entry.usageStatus == .reported || entry.usageStatus == .estimated else { return nil } let usage = entry.usage @@ -329,13 +356,15 @@ 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, cacheWriteInputTokens: cacheWrite, - pricingDate: entry.timestamp) + pricingDate: entry.timestamp, + modelsDevCatalog: modelsDevCatalog) } private static func add(_ lhs: Int?, _ rhs: Int?) -> Int? { 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 51f893cf9b..70f1203d38 100644 --- a/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift +++ b/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift @@ -1,7 +1,7 @@ -import CodexBarCore import Foundation import Testing @testable import CodexBar +@testable import CodexBarCore struct OpenCodexUsageFanOutTests { @Test func `snapshotsBySubscription routes openai spend into codex`() throws { @@ -29,6 +29,86 @@ 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)) @@ -168,4 +248,68 @@ struct OpenCodexUsageFanOutTests { let result = SpendDashboardSource.mergingOpenCodexInputs([dummy], request: request) #expect(!result.contains(where: { $0.id == SpendDashboardModel.openCodexSourceID })) } + + 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 348d1c8186..7f14470201 100644 --- a/docs/grok.md +++ b/docs/grok.md @@ -122,6 +122,16 @@ The grok.com billing gRPC-web endpoint remains a best-effort fallback. - Credits `subscriptionTier` maps SuperGrok vs SuperGrok Heavy on the plan badge. SuperGrok Heavy with no `creditUsagePercent` is unknown usage, not 0%. +## 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 daf3b86b23d2bff29a7c250ee94350963055daa4 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Fri, 21 Aug 2026 22:34:00 -0700 Subject: [PATCH 03/11] 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 | 14 ++- .../Providers/Grok/GrokStatusProbe.swift | 2 +- .../GrokLocalSessionScannerTests.swift | 109 ++++++++++++++++++ 5 files changed, 160 insertions(+), 8 deletions(-) diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index eee43b2736..f1ad86b9b0 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -510,7 +510,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 b90a98930b..01070398b0 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 139bcbc71f..a72e90badf 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift @@ -334,11 +334,12 @@ struct GrokWebFetchStrategy: ProviderFetchStrategy { throw GrokWebBillingError.teamUsageUnsupported } let subscriptionTier = try await resolveSettingsTier(authState) + let localSummary = await GrokLocalSessionScanner.summarizeRequestingPricingRefresh( + env: context.env, + lookbackDays: GrokLocalSessionScanner.maximumLookbackDays) let identitySnapshot = GrokStatusProbe.identityOnlySnapshot( credentials: authState, - localSummary: GrokLocalSessionScanner.summarize( - env: context.env, - lookbackDays: GrokLocalSessionScanner.maximumLookbackDays), + localSummary: localSummary, cliVersion: GrokStatusProbe.detectVersion(env: context.env), subscriptionTier: subscriptionTier) return self.makeResult( @@ -358,6 +359,9 @@ struct GrokWebFetchStrategy: ProviderFetchStrategy { nil } let enrichedBilling = webBilling.applying(subscriptionTier: subscriptionTier) + let localSummary = await GrokLocalSessionScanner.summarizeRequestingPricingRefresh( + env: context.env, + lookbackDays: GrokLocalSessionScanner.maximumLookbackDays) let snapshot = GrokUsageSnapshot( billing: nil, webBilling: enrichedBilling, @@ -365,9 +369,7 @@ struct GrokWebFetchStrategy: ProviderFetchStrategy { credentials: credentials, billing: nil, webBilling: enrichedBilling), - localSummary: GrokLocalSessionScanner.summarize( - env: context.env, - lookbackDays: GrokLocalSessionScanner.maximumLookbackDays), + localSummary: localSummary, cliVersion: GrokStatusProbe.detectVersion(env: context.env), updatedAt: Date(), subscriptionTier: subscriptionTier ?? enrichedBilling.subscriptionTier) diff --git a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift index 0d3cd38966..7f338916c3 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift @@ -121,7 +121,7 @@ public struct GrokStatusProbe: Sendable { } // Local fallback summary always succeeds (empty if no sessions yet). - let localSummary = GrokLocalSessionScanner.summarize( + 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 ed99f4bd37c84df5be994c73113f24f6a85690fb Mon Sep 17 00:00:00 2001 From: olddonkey Date: Fri, 21 Aug 2026 22:53:00 -0700 Subject: [PATCH 04/11] 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 6d9aa701bb9c47a9056d64d992fc6a4ab891a67f Mon Sep 17 00:00:00 2001 From: olddonkey Date: Sat, 22 Aug 2026 00:17:12 -0700 Subject: [PATCH 05/11] 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 8b525bc17d7fa769b960d15c81174ac634b5a1c6 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Sat, 22 Aug 2026 01:22:39 -0700 Subject: [PATCH 06/11] 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 01070398b0..b96ae92d88 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 8bec20f312..66fe3b7ec7 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 70f1203d38..e5e199d418 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( @@ -278,6 +287,46 @@ struct OpenCodexUsageFanOutTests { 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 7f14470201..8948101253 100644 --- a/docs/grok.md +++ b/docs/grok.md @@ -126,12 +126,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 15c059b2ac8d72c6ddb9ce090e7419bcf3ec1799 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Sun, 23 Aug 2026 02:30:30 -0700 Subject: [PATCH 07/11] 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 b96ae92d88..3cacfb0cf5 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 66fe3b7ec7..8bec20f312 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 e5e199d418..22b9c9b4e3 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 { @@ -308,25 +282,6 @@ struct OpenCodexUsageFanOutTests { ] } - 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 8948101253..ac4b3e7806 100644 --- a/docs/grok.md +++ b/docs/grok.md @@ -124,14 +124,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 bdfc10e0d2366a7b21e888ed34421e9b1b9ff6f1 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Sun, 23 Aug 2026 03:16:29 -0700 Subject: [PATCH 08/11] Bound Grok session log scanning --- .../Grok/GrokLocalSessionScanner.swift | 388 ++++++++++++++---- .../GrokLocalSessionScannerTests.swift | 109 +++++ docs/grok.md | 60 ++- 3 files changed, 454 insertions(+), 103 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift index 3cacfb0cf5..0f3da4cd37 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) } static func parseCacheMetricsForTesting() -> GrokLocalSessionParseCacheMetrics { @@ -502,6 +610,10 @@ public enum GrokLocalSessionScanner { self.parseCache.entryCount() } + static func parseCacheTurnCountForTesting() -> Int { + self.parseCache.cachedTurnCount() + } + private static func emptySummary(now: Date) -> GrokLocalSessionSummary { GrokLocalSessionSummary( sessionCount: 0, @@ -520,17 +632,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? { @@ -581,8 +791,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 ac4b3e7806..faec7f74ae 100644 --- a/docs/grok.md +++ b/docs/grok.md @@ -96,10 +96,16 @@ The grok.com billing gRPC-web endpoint remains a best-effort fallback. returned by some successful requests. A current billing period with an omitted proto3 `credit_usage_percent` is treated as zero usage. 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 @@ -189,28 +195,48 @@ 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. +credits remain a quota window on the usage bar; public xAI list-price dollars are +shown only as a non-billed estimate and are not a conversion of subscription credits. ## Status From d095d12ced848064b3a7e6f7ad252b2ad9ef2193 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Sun, 23 Aug 2026 03:36:37 -0700 Subject: [PATCH 09/11] 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 0f3da4cd37..3d5ab858ea 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift @@ -719,7 +719,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) @@ -743,6 +743,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 15c7963e72f738dc2c4820ac8cec09429a6e5158 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Sun, 23 Aug 2026 11:39:25 -0700 Subject: [PATCH 10/11] Document Grok usage pricing --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f8089512d..797af971d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ - Fixed the Codex cost card blanking Today and recent days when priority processing is in use: row-ownership evidence now accepts the trace database's tier classification instead of discarding whole (day, model) groups (#3150). Thanks @olddonkey! - Stopped re-merging the Codex plan-utilization history with itself on every refresh and menu open — the legacy-bucket migration now runs only when a non-canonical bucket actually exists (#3141). Thanks @olddonkey! +### 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 3b642530a91688fe57a45a4cb6549795deb0ae24 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Sun, 23 Aug 2026 13:07:54 -0700 Subject: [PATCH 11/11] Deduplicate compatible parser hash --- Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift | 3 +-- Tests/CodexBarTests/CostUsageStoreTests.swift | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift index 678891bcfb..7ef35c0f13 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift @@ -77,13 +77,12 @@ actor CostUsageStore { parserHash: CodexParserHash.value) static let cacheGeneration = "sqlite:\(CostUsageStore.schemaVersion)" static let compatiblePredecessorParserHashes: Set = [ - "3c984b655688593f", // xAI pricing lookup only; persisted Codex 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. ] /// Test-only crash injection: invoked inside `saveCodexCache`'s transaction after each diff --git a/Tests/CodexBarTests/CostUsageStoreTests.swift b/Tests/CodexBarTests/CostUsageStoreTests.swift index cf491e0108..46f4ef4bef 100644 --- a/Tests/CodexBarTests/CostUsageStoreTests.swift +++ b/Tests/CodexBarTests/CostUsageStoreTests.swift @@ -1011,7 +1011,6 @@ extension CostUsageStoreTests { "b975eb705f905b9a", "47144baa8daccf52", "2d17f4981b78d07f", - "3c984b655688593f", ]) let predecessorHash = "3c984b655688593f" let predecessorVersion = CostUsageStore.combinedSchemaVersion(