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 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..f1ad86b9b0 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) { + await GrokLocalSessionScanner.summarizeRequestingPricingRefresh( + 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..3d5ab858ea 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 @@ -25,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, @@ -33,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 @@ -42,138 +79,1019 @@ public struct GrokLocalSessionSummary: Sendable { self.models = models self.daily = daily self.scannedAt = scannedAt + self.historyCoverageIsEstablished = historyCoverageIsEstablished } - /// 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, + historyCoverageIsEstablished: self.historyCoverageIsEstablished, + costProvenance: .listPriceEstimate, daily: entries, updatedAt: self.scannedAt) } } +struct GrokLocalSessionParseCacheMetrics: Sendable, Equatable { + let fileDecodeCount: Int + 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 + 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 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 Identity: Equatable { + let size: Int + let mtimeIntervalSince1970: TimeInterval + 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, + limits: GrokLocalSessionScanLimits, + decode: () -> GrokTurnDecodeResult) -> GrokParsedTurnBatch + { + let identity = Identity( + size: size, + mtimeIntervalSince1970: mtimeIntervalSince1970, + limits: limits) + self.lock.lock() + 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.batch + } + self.lock.unlock() + + let decoded = decode() + self.lock.lock() + defer { self.lock.unlock() } + self.fileDecodeCount += 1 + self.jsonDecodeCount += decoded.jsonDecodeCount + 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( + 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 { + self.lock.lock() + defer { self.lock.unlock() } + 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() } + 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 + 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) + } + } +} + public enum GrokLocalSessionScanner { public static let defaultLookbackDays = 30 + public static let maximumLookbackDays = 365 + + private static let maximumValidatedModelCalls = 10000 + + 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 + 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) + + /// 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, + 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 + { + let hasCachedCatalog = ModelsDevCache.load(now: now, cacheRoot: modelsDevCacheRoot).artifact != nil + if hasCachedCatalog { + Task.detached(priority: .utility) { + await requestPricingRefresh() + } + } else { + await requestPricingRefresh() + } + return self.summarize( + env: env, + fileManager: fileManager, + lookbackDays: lookbackDays, + now: now, + pricing: PricingContext( + modelsDevCatalog: nil, + modelsDevCacheRoot: modelsDevCacheRoot, + customPricing: .empty)) + } - /// Walk `~/.grok/sessions///signals.json` and aggregate stats. + /// Walk `~/.grok/sessions///updates.jsonl` and aggregate completed turns. public static func summarize( env: [String: String] = ProcessInfo.processInfo.environment, fileManager: FileManager = .default, lookbackDays: Int = defaultLookbackDays, now: Date = .init()) -> GrokLocalSessionSummary + { + self.summarize( + env: env, + fileManager: fileManager, + lookbackDays: lookbackDays, + now: now, + pricing: PricingContext( + modelsDevCatalog: nil, + modelsDevCacheRoot: nil, + customPricing: .empty)) + } + + static func summarize( + env: [String: String], + fileManager: FileManager = .default, + lookbackDays: Int = defaultLookbackDays, + now: Date = .init(), + modelsDevCatalog: ModelsDevCatalog, + modelsDevCacheRoot: URL? = nil, + customPricing: CostUsageCustomPricing? = .empty, + scanLimits: GrokLocalSessionScanLimits = .production) -> GrokLocalSessionSummary + { + self.summarize( + env: env, + fileManager: fileManager, + lookbackDays: lookbackDays, + now: now, + pricing: PricingContext( + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot, + customPricing: customPricing), + scanLimits: scanLimits) + } + + 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, + scanLimits: GrokLocalSessionScanLimits = .production) -> GrokLocalSessionSummary { let root = GrokCredentialsStore.grokHomeURL(env: env, fileManager: fileManager) .appendingPathComponent("sessions", isDirectory: true) - guard let rootEnum = fileManager.enumerator( - at: root, - includingPropertiesForKeys: [.contentModificationDateKey, .isDirectoryKey], - options: [.skipsHiddenFiles]) + var visitedCachePaths: Set = [] + defer { self.parseCache.retainEntries(at: visitedCachePaths) } + let calendar = Calendar.current + let lookbackCutoff = calendar.date(byAdding: .day, value: -max(0, lookbackDays), to: now) ?? now + guard let sessionSelection = self.recentSessionPaths( + root: root, + fileManager: fileManager, + lookbackCutoff: lookbackCutoff, + maximumCount: scanLimits.maximumSessions) else { - return GrokLocalSessionSummary( - sessionCount: 0, - totalTokens: 0, - lastSessionAt: nil, - primaryModel: nil, - models: [], - scannedAt: now) + return self.emptySummary(now: now) } - let calendar = Calendar.current - let lookbackCutoff = calendar.date(byAdding: .day, value: -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 } + var aggregation = ScanAggregation() + var remainingTotalBytes = scanLimits.maximumTotalBytes + var remainingTotalTurns = scanLimits.maximumTotalTurns + var historyCoverageIsEstablished = sessionSelection.historyCoverageIsEstablished - 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 + for sessionPath in sessionSelection.paths { + guard !Task.isCancelled else { return self.emptySummary(now: now) } + guard remainingTotalBytes > 0, remainingTotalTurns > 0 else { + historyCoverageIsEstablished = false + break } - - var sessionModels: [String] = [] - if let primary = (json["primaryModelId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), - !primary.isEmpty + let sessionURL = URL(fileURLWithPath: sessionPath, isDirectory: true) + var updatesYieldedCompletedTurns = false + let updates = sessionURL.appendingPathComponent("updates.jsonl") + if 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) + 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 parsed = self.parseCache.turns( + path: updates.path, + size: identity.size, + mtimeIntervalSince1970: identity.modificationDate.timeIntervalSince1970, + limits: fileLimits) + { + self.decodeTurns(at: updates, fileSize: identity.size, limits: fileLimits) + } + 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 { + 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 + let fallback = sessionURL.appendingPathComponent("signals.json") + if !updatesYieldedCompletedTurns, + let identity = self.fileIdentity(for: fallback), + identity.modificationDate >= lookbackCutoff + { + 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 + } } } } - 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, + historyCoverageIsEstablished: historyCoverageIsEstablished) + } + + static func parseCacheMetricsForTesting() -> GrokLocalSessionParseCacheMetrics { + self.parseCache.metrics() + } + + static func resetParseCacheForTesting() { + self.parseCache.reset() + } + + static func parseCacheEntryCountForTesting() -> Int { + self.parseCache.entryCount() + } + + static func parseCacheTurnCountForTesting() -> Int { + self.parseCache.cachedTurnCount() + } + + 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 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 + 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 = self.decodeTurnWithScopedAutoreleasePool(line.bytes) else { return } + turns.append(turn) + if turns.count > compactionThreshold { + turns.removeFirst(limits.maximumTurnsPerFile) + droppedTurns = true + } + }) + } catch { + historyCoverageIsEstablished = false + cacheable = false + } + + 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 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"]), + 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, 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] = [] + 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..a72e90badf 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 @@ -334,9 +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), + localSummary: localSummary, cliVersion: GrokStatusProbe.detectVersion(env: context.env), subscriptionTier: subscriptionTier) return self.makeResult( @@ -356,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, @@ -363,7 +369,7 @@ struct GrokWebFetchStrategy: ProviderFetchStrategy { credentials: credentials, billing: nil, webBilling: enrichedBilling), - localSummary: GrokLocalSessionScanner.summarize(env: context.env), + 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 a326a21976..7f338916c3 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 = await GrokLocalSessionScanner.summarizeRequestingPricingRefresh( + 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..7ef35c0f13 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift @@ -77,12 +77,12 @@ actor CostUsageStore { parserHash: CodexParserHash.value) static let cacheGeneration = "sqlite:\(CostUsageStore.schemaVersion)" static let compatiblePredecessorParserHashes: Set = [ + "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/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexRouteDispatcher.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexRouteDispatcher.swift index aa80374dcc..fbc483cae0 100644 --- a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexRouteDispatcher.swift +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexRouteDispatcher.swift @@ -9,19 +9,25 @@ public enum OpenCodexRouteTarget: Equatable, Sendable { public enum OpenCodexRouteDispatcher { public static func route(provider: String) -> 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": + // 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": - .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 } } 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/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageModels.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageModels.swift index b139b9394c..a432bcd80b 100644 --- a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageModels.swift +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageModels.swift @@ -123,17 +123,7 @@ 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) } @@ -148,6 +138,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/CostUsageStoreTests.swift b/Tests/CodexBarTests/CostUsageStoreTests.swift index 0b64255256..46f4ef4bef 100644 --- a/Tests/CodexBarTests/CostUsageStoreTests.swift +++ b/Tests/CodexBarTests/CostUsageStoreTests.swift @@ -1005,14 +1005,14 @@ extension CostUsageStoreTests { let fixture = try StoreFixture() defer { fixture.remove() } #expect(CostUsageStore.compatiblePredecessorParserHashes == [ + "3c984b655688593f", "98da5914d2f6a9cd", "43609cc56f76a003", "b975eb705f905b9a", "47144baa8daccf52", "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..b46fb8e850 100644 --- a/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift +++ b/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift @@ -1,101 +1,613 @@ 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) + 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 `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 `absent models dev cache requests an initial 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 `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() + 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() + 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 `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(snapshot.last30DaysTokens == 100) - #expect(snapshot.sessionTokens == nil) + + #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 `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], + 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: Date()) - #expect(summary.toCostUsageTokenSnapshot(historyDays: 7) == nil) + 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() + 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) + + 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 +618,76 @@ 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) + 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 +} diff --git a/Tests/CodexBarTests/GrokXAISpendCatalogTests.swift b/Tests/CodexBarTests/GrokXAISpendCatalogTests.swift index c58a1a4f09..df94354c5f 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( @@ -23,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: ","))") } diff --git a/Tests/CodexBarTests/OpenCodexRouteDispatcherTests.swift b/Tests/CodexBarTests/OpenCodexRouteDispatcherTests.swift index 234e7100d1..1d2d5955a5 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.tokenOnly), + (" XAI\n", OpenCodexRouteTarget.tokenOnly), ("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,13 @@ struct OpenCodexRouteDispatcherTests { provider: "opencode-go", modelName: "gpt-5.2") == .subscription(.opencodego)) } + + @Test + func `explicit xai model prefix stays token only without record time auth evidence`() { + #expect(OpenCodexRouteDispatcher.route(modelName: "xai/grok-4.6") == .tokenOnly) + #expect( + OpenCodexRouteDispatcher.route( + provider: "openai", + modelName: " xai/grok-4.6 ") == .tokenOnly) + } } diff --git a/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift b/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift index 51f893cf9b..22b9c9b4e3 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,69 @@ struct OpenCodexUsageFanOutTests { #expect(snapshots[.codex]?.last30DaysTokens == 150) } + @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) + let entries = Self.xaiEntries(now: now) + [ + 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]) + #expect(snapshots[.grok] == nil) + #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 +231,89 @@ 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 xaiEntries(now: Date) -> [OpenCodexUsageEntry] { + [ + OpenCodexUsageEntry( + requestID: "xai-1", + timestamp: now, + provider: "xai", + model: "grok-4.6", + usageStatus: .reported, + usage: OpenCodexTokenUsage(inputTokens: 100, outputTokens: 20, totalTokens: 120), + totalTokens: 120), + OpenCodexUsageEntry( + requestID: "xai-2", + timestamp: now, + provider: "xai", + model: "xai/grok-4.6", + usageStatus: .reported, + usage: OpenCodexTokenUsage(inputTokens: 60, outputTokens: 20, totalTokens: 80), + totalTokens: 80), + ] + } + + private static func pricingCatalog() throws -> ModelsDevCatalog { + let json = """ + { + "xai": { + "id": "xai", + "models": { + "grok-4.6": { + "id": "grok-4.6", + "cost": { "input": 2, "output": 6, "cache_read": 0.5 } + } + } + }, + "openai": { + "id": "openai", + "models": { + "gpt-5.6-sol": { + "id": "gpt-5.6-sol", + "cost": { "input": 1, "output": 4, "cache_read": 0.25 } + } + } + }, + "kimi": { + "id": "kimi", + "models": { + "k3[1m]": { + "id": "k3[1m]", + "cost": { "input": 9, "output": 19 } + } + } + } + } + """ + return try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8)) + } } 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, diff --git a/docs/grok.md b/docs/grok.md index 348d1c8186..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 @@ -122,6 +128,13 @@ 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 `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 @@ -182,28 +195,48 @@ The grok.com billing gRPC-web endpoint remains a best-effort fallback. ## 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