diff --git a/CHANGELOG.md b/CHANGELOG.md index 035d05b463..d075cd837a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -207,6 +207,8 @@ - Copilot: add optional per-account AI credit allowances and a way to clear legacy defaults, retaining matching cached usage during offline edits and preserving independent reset baselines (#2647). Thanks @KSEGIT! - Grok: add a visor critter across single- and two-meter quota layouts, respecting Hide Critters (#3028). Thanks @sm0keyyy! - Usage & Spend: count covered calendar days, align chart labels and ranges with the selected bucket time zone, and preserve historical-pace credits after midnight daylight-saving transitions (follow-up to #3565). +- Grok: count completed-turn usage from bounded local CLI session-log scans instead of context-window occupancy, and price it from the spend the CLI recorded, falling back to clearly labeled public xAI list prices where it recorded none; standalone OpenCodex xAI history remains token-only unless the user supplies explicit custom prices (#3135, #3345). Thanks @olddonkey and @initH271! +- Usage & Spend: include reported OpenCodex Grok OAuth attempts when log import is enabled and the log records request-time credential provenance. Keep API-key and historic traffic excluded, and label OpenCodex dollars as list-price estimates (#3135). Thanks @olddonkey! - Usage & Spend: preserve heatmap coverage, token totals, and daily ledger rows across midnight daylight-saving transitions, retaining real gaps and unscanned days (#3565). Thanks @gabrielrojasc! - Cursor on Linux: restore automatic authentication from the signed-in app, honor absolute XDG/HOME paths, and preserve manual-cookie precedence and explicit web-mode isolation (#3539). Thanks @DonnieFi! - Codex spend: exclude time waiting behind other scans from automatic catch-up sleep calculations while preserving scan budgets, power safeguards, and complete-history publication (#3566, related to #3508 and #3411). diff --git a/Scripts/capture_grok_opencodex_proof.py b/Scripts/capture_grok_opencodex_proof.py new file mode 100755 index 0000000000..e4f3dc8ee0 --- /dev/null +++ b/Scripts/capture_grok_opencodex_proof.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Capture real OpenCodex usage-writer output using its isolated upstream fixtures.""" + +import argparse +import hashlib +import json +import pathlib +import re +import subprocess + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--opencodex-root", required=True, type=pathlib.Path) + parser.add_argument("--output-dir", required=True, type=pathlib.Path) + args = parser.parse_args() + root = args.opencodex_root.resolve() + expected = "146ed679c9633e5d68726217fcadc8e0b107339b" + head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=root, text=True).strip() + dirty = subprocess.check_output(["git", "status", "--porcelain"], cwd=root, text=True).strip() + if head != expected or dirty: + parser.error("Use a clean OpenCodex checkout at " + expected) + output = args.output_dir.resolve() + output.mkdir(parents=True, exist_ok=False) + ledger = output / "usage.jsonl" + source = root / "tests/server/server-xai-oauth-401-replay.test.ts" + original = source.read_text() + captured_test = original.replace( + "mkdtempSync, readFileSync}", + "mkdtempSync, readFileSync, existsSync, appendFileSync}", + 1, + ).replace( + "afterEach(() => {", + "afterEach(() => {\n if (existsSync(usageLogPath())) appendFileSync(" + + json.dumps(str(ledger)) + ", readFileSync(usageLogPath()));", + 1, + ).replace( + "return originalFetch(input, init);", + 'throw new Error("Unexpected upstream request in isolated producer proof");', + ) + captured_test = re.sub( + r'from "(\.[^"]+)"', + lambda match: "from " + json.dumps(str((source.parent / match[1]).resolve())), + captured_test, + ) + test_file = output / "producer-proof.test.ts" + test_file.write_text(captured_test) + bun = root / "node_modules/.bin/bun" + version = subprocess.check_output([str(bun), "--version"], cwd=root, text=True).strip() + if version != "1.4.0": + parser.error("Use repository-pinned Bun 1.4.0") + with (output / "terminal.log").open("w") as terminal: + subprocess.run( + [str(bun), "test", str(test_file), "--test-name-pattern", + "401 then 200 performs one refresh and one replay|native Chat records canonical API-key provenance"], + cwd=root, stdout=terminal, stderr=subprocess.STDOUT, check=True, + ) + raw = ledger.read_bytes() + rows = [json.loads(line) for line in raw.splitlines()] + assert len(rows) == 2 + assert all(secret not in raw for secret in [ + b"rejected-access", b"fresh-access", b"initial-refresh", b"xai-test-account", b"Bearer ", + ]) + attempts = [attempt for row in rows for attempt in row["attempts"]] + assert {row["credentialSource"] for row in attempts} == {"grok-oauth", "xai-api-key"} + result = { + "producerRepository": "https://github.com/lidge-jun/opencodex", + "producerCommit": head, + "bunVersion": version, + "upstream": "isolated fixtures; no live provider requests", + "sha256": hashlib.sha256(raw).hexdigest(), + "rows": len(rows), + "attempts": [{key: row[key] for key in [ + "credentialSource", "adapter", "sendCount", "totalTokens", + ]} for row in attempts], + } + (output / "capture.json").write_text(json.dumps(result, indent=2) + "\n") + print(json.dumps(result, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index 70240049c6..8122de8a40 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -217,15 +217,8 @@ extension UsageMenuCardView.Model { preferredCurrency: preferredCurrencyCode, providerCurrency: snapshot.currencyCode) } ?? "—" - let fallbackTokens: Int? = { - var sum = 0 - for t in snapshot.daily.compactMap(\.totalTokens) { - let (res, of) = sum.addingReportingOverflow(t) - if of { return nil } - sum = res - } - return sum > 0 ? sum : nil - }() + let fallbackTokens = CostUsageDailyReport.completeCountSum(snapshot.daily.map(\.totalTokens)) + .flatMap { $0 > 0 ? $0 : nil } let monthTokensValue = snapshot.last30DaysTokens ?? fallbackTokens let monthTokens = monthTokensValue.map { UsageFormatter.tokenCountString($0) } let windowLabel = if let historyLabel = snapshot.historyLabel { diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index 168fb31632..9b7a8efd16 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -654,7 +654,15 @@ private struct SpendProviderPanel: View { .foregroundStyle(.tertiary) .frame(width: 26, alignment: .leading) SpendProviderIcon(provider: row.provider, sourceKind: row.sourceKind) - Text(row.displayName).lineLimit(1) + VStack(alignment: .leading, spacing: 2) { + Text(row.displayName).lineLimit(1) + if let disclaimer = row.costDisclaimer { + Text(disclaimer) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } Spacer() Text( row.totalCost == nil && row.totalTokens == nil && row.incompleteRequestCount == 0 diff --git a/Sources/CodexBar/Providers/Grok/UsageStore+GrokLocalSessions.swift b/Sources/CodexBar/Providers/Grok/UsageStore+GrokLocalSessions.swift index 46ca169a03..7e3e0245b4 100644 --- a/Sources/CodexBar/Providers/Grok/UsageStore+GrokLocalSessions.swift +++ b/Sources/CodexBar/Providers/Grok/UsageStore+GrokLocalSessions.swift @@ -20,22 +20,23 @@ extension UsageStore { else { return nil } let daily = published.daily.filter { $0.date >= firstDay && $0.date <= lastDay } guard !daily.isEmpty else { return nil } - let tokens = daily.compactMap(\.totalTokens) - let requests = daily.compactMap(\.requestCount) + // Tokens and requests are recomputed from the retained days, so the cost has to be too. Copying the + // published total would render the full 365-day amount beside a 30-day token count. + let costs = daily.compactMap(\.costUSD) return CostUsageTokenSnapshot( sessionTokens: published.sessionTokens, sessionCostUSD: published.sessionCostUSD, sessionRequests: published.sessionRequests, - last30DaysTokens: tokens.isEmpty ? nil : tokens.reduce(0, +), - last30DaysCostUSD: published.last30DaysCostUSD, - last30DaysRequests: requests.isEmpty ? nil : requests.reduce(0, +), + last30DaysTokens: CostUsageDailyReport.completeCountSum(daily.map(\.totalTokens)), + last30DaysCostUSD: costs.isEmpty ? nil : costs.reduce(0, +), + last30DaysRequests: CostUsageDailyReport.completeCountSum(daily.map(\.requestCount)), currencyCode: published.currencyCode, historyDays: days, historyCoverageIsEstablished: published.historyCoverageIsEstablished && published.historyDays >= days, historyLabel: published.historyLabel, meteredCostUSD: published.meteredCostUSD, - costProvenance: published.costProvenance, + costProvenance: GrokLocalSessionSummary.costProvenance(for: daily, fallback: published.costProvenance), credentialScopeFingerprint: published.credentialScopeFingerprint, daily: daily, projects: published.projects, @@ -51,6 +52,35 @@ extension UsageStore { return summary.toCostUsageTokenSnapshot(historyDays: historyDays) } + /// Generic window math does not know that Grok's priced rows are CLI-recorded spend. Apply the same + /// source-aware disclosure to live publications and scan results as to the remote-backed projection. + func narrowedGrokTokenSnapshot(_ published: CostUsageTokenSnapshot, historyDays: Int) -> CostUsageTokenSnapshot { + let narrowed = published.narrowed( + toHistoryDays: historyDays, + calendar: self.settings.costUsageBucketCalendar) + return CostUsageTokenSnapshot( + sessionTokens: narrowed.sessionTokens, + sessionCostUSD: narrowed.sessionCostUSD, + sessionRequests: narrowed.sessionRequests, + last30DaysTokens: narrowed.last30DaysTokens, + last30DaysCostUSD: narrowed.last30DaysCostUSD, + last30DaysRequests: narrowed.last30DaysRequests, + currencyCode: narrowed.currencyCode, + historyDays: narrowed.historyDays, + historyCoverageIsEstablished: narrowed.historyCoverageIsEstablished, + historyLabel: narrowed.historyLabel, + meteredCostUSD: narrowed.meteredCostUSD, + costProvenance: GrokLocalSessionSummary.costProvenance( + for: narrowed.daily, + fallback: published.costProvenance), + credentialScopeFingerprint: narrowed.credentialScopeFingerprint, + daily: narrowed.daily, + projects: narrowed.projects, + sessions: narrowed.sessions, + hourly: narrowed.hourly, + updatedAt: narrowed.updatedAt) + } + private static func grokLocalDayKey(for date: Date, calendar: Calendar) -> String? { let parts = calendar.dateComponents([.year, .month, .day], from: date) guard let year = parts.year, let month = parts.month, let day = parts.day else { return nil } diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index a4d76e8167..eb1f220282 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -293,15 +293,21 @@ enum SpendDashboardSource { // Provider-specific by design: Grok local session tokens are independent of the // remote billing snapshot, so a failed probe still publishes readable logs. if provider == .grok { - if let snapshot = store.tokenSnapshot( - fromProviderSnapshot: store.snapshot(for: .grok), - provider: .grok, - historyDays: Self.scanDays) - { + let remote = store.snapshot(for: .grok) + let publication = store.tokenSnapshotPublicationForCurrentProviderConfig(for: .grok) + let grokSnapshot = if remote != nil || publication != nil { + store.tokenSnapshotForLiveProviderConsumer( + fromProviderSnapshot: remote, + provider: .grok, + historyDays: Self.scanDays) + } else { + 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) } @@ -871,10 +877,9 @@ 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( + return store.tokenSnapshotForLiveProviderConsumer( fromProviderSnapshot: store.snapshot(for: .grok), provider: .grok, historyDays: self.scanDays) diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index 6cbed96446..38370e051d 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -55,6 +55,12 @@ struct SpendDashboardModel: Equatable, Sendable { let incompleteRequestCount: Int + var costDisclaimer: String? { + // Provider-specific by design: Grok local-session dollars are list-price estimates, not billed spend. + guard self.provider == .grok, self.totalCost != nil else { return nil } + return UsageFormatter.costEstimateHint(provider: self.provider) + } + init( id: String, rank: Int, @@ -551,7 +557,15 @@ struct SpendDashboardModel: Equatable, Sendable { metered = (metered ?? 0) + meteredCost * summary.costMultiplier } if summary.totalCost != nil { - switch summary.input.snapshot.costProvenance { + // Provider-specific by design: Grok owns the recorded-versus-estimated meaning of its row coverage. + let provenance = if summary.input.provider == .grok { + GrokLocalSessionSummary.costProvenance( + for: summary.entries.map(\.entry), + fallback: summary.input.snapshot.costProvenance) + } else { + summary.input.snapshot.costProvenance + } + switch provenance { case .vendorMetered: sawVendorMeteredProvenance = true case .listPriceEstimate: diff --git a/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift b/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift index 6042dd2f43..209bf62917 100644 --- a/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift +++ b/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift @@ -46,6 +46,7 @@ extension SpendDashboardSource { _ inputs: [SpendDashboardModel.ProviderInput], request: SpendDashboardLoadRequest, environment: [String: String] = ProcessInfo.processInfo.environment, + cacheRoot: URL? = nil, entryLoader: ((URL) throws -> [OpenCodexUsageEntry])? = nil) -> ( inputs: [SpendDashboardModel.ProviderInput], observation: SpendDashboardLoadResult.OpenCodexObservation) @@ -60,7 +61,7 @@ extension SpendDashboardSource { guard let logURL = OpenCodexUsageLog.usageLogURL(environment: environment) else { return (inputs.filter { $0.id != SpendDashboardModel.openCodexSourceID }, .unavailable) } - let store = OpenCodexUsageStore(cacheRoot: OpenCodexUsageLog.cacheRoot()) + let store = OpenCodexUsageStore(cacheRoot: cacheRoot ?? OpenCodexUsageLog.cacheRoot()) let entries: [OpenCodexUsageEntry] do { entries = try entryLoader?(logURL) ?? store.loadEntries(logURL: logURL) @@ -144,7 +145,8 @@ extension SpendDashboardSource { supplement, now: request.now, historyDays: self.scanDays, - calendar: request.configuration.bucketCalendar), + calendar: request.configuration.bucketCalendar, + provider: input.provider), tokenActivityCache: input.tokenActivityCache, sourceKind: input.sourceKind) } diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index b49ca5b3ea..48caa0cc00 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1558,7 +1558,7 @@ extension StatusItemController { } func tokenSnapshotForCostHistorySubmenu(provider: UsageProvider) -> CostUsageTokenSnapshot? { - let projected = self.store.tokenSnapshot( + let projected = self.store.tokenSnapshotForLiveProviderConsumer( fromProviderSnapshot: self.store.snapshot(for: provider.instanceID), provider: provider) if UsageStore.tokenCostRequiresProviderSnapshot(provider) { diff --git a/Sources/CodexBar/UsageStore+MenuCardModel.swift b/Sources/CodexBar/UsageStore+MenuCardModel.swift index d3cae14308..b80a2358fb 100644 --- a/Sources/CodexBar/UsageStore+MenuCardModel.swift +++ b/Sources/CodexBar/UsageStore+MenuCardModel.swift @@ -65,9 +65,13 @@ extension UsageStore { if isSettings { tokenSnapshot = supportsTokenCost ? self.tokenSnapshot(for: provider) : nil } else { - let projected = isLive || snapshot != nil - ? self.tokenSnapshot(fromProviderSnapshot: snapshot, provider: provider) - : nil + let projected: CostUsageTokenSnapshot? = if isLive { + self.tokenSnapshotForLiveProviderConsumer(fromProviderSnapshot: snapshot, provider: provider) + } else if snapshot != nil { + self.tokenSnapshot(fromProviderSnapshot: snapshot, provider: provider) + } else { + nil + } let stored = isLive && supportsTokenCost && !Self.tokenCostRequiresProviderSnapshot(provider) ? self.tokenSnapshot(for: provider) : nil diff --git a/Sources/CodexBar/UsageStore+QuotaWarnings.swift b/Sources/CodexBar/UsageStore+QuotaWarnings.swift index 283845daf1..91dfdabe1c 100644 --- a/Sources/CodexBar/UsageStore+QuotaWarnings.swift +++ b/Sources/CodexBar/UsageStore+QuotaWarnings.swift @@ -29,6 +29,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 e629703575..384427d763 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -1342,17 +1342,20 @@ extension UsageStore { attempts: [ProviderFetchAttempt], context: ProviderRefreshOutcomeContext) async { - // Provider-specific by design: Grok's local fallback scans off the main thread when remote billing fails. - let grokLocalFallback: CostUsageTokenSnapshot? = if provider == .grok { - try? await self.loadGrokLocalTokenSnapshot(historyDays: SpendDashboardSource.scanDays) - } else { - nil - } guard !Task.isCancelled else { return } let shouldNotifyPermissionPrompt = Self.isPermissionPromptWaiting(error) await MainActor.run { guard self.isCurrentProviderRefreshGeneration(provider, generation: context.generation) else { return } self.diagnostics[provider.instanceID] = nil + // Provider-specific by design: local ~/.grok/sessions tokens remain readable and + // continue advancing after every remote billing failure, including transient failures + // that preserve the prior provider snapshot. + if provider == .grok { + Task { @MainActor [weak self] in + await self?.scanAndPublishGrokLocalTokenSnapshot( + historyDays: GrokLocalSessionScanner.maximumLookbackDays) + } + } let restoredClaudeHistory = self.prepareClaudeHistoryFallback( provider: provider, usesConsumerAutoPipeline: context.claudeUsesConsumerAutoPipeline, @@ -1469,15 +1472,8 @@ extension UsageStore { self.errors[provider.instanceID] = error.localizedDescription if !preservesPriorData, !preservesClaudeWebSessionFailure { self.snapshots.removeValue(forKey: provider.instanceID) - // Provider-specific by design: local ~/.grok/sessions tokens remain readable - // when the remote billing probe fails. - if provider == .grok { - if let local = grokLocalFallback { - self.publishTokenSnapshot(local, for: provider) - } else { - self.clearTokenSnapshot(for: provider) - } - } else if Self.tokenCostRequiresProviderSnapshot(provider) { + // Provider-specific by design: Grok already published its independent local scan above. + if provider != .grok, Self.tokenCostRequiresProviderSnapshot(provider) { self.clearTokenSnapshot(for: provider) } } diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index cf3acb6d7e..eafb68ed29 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -528,8 +528,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() @@ -554,6 +554,60 @@ extension UsageStore { } } + @discardableResult + func scanAndPublishGrokLocalTokenSnapshot(historyDays: Int) async -> CostUsageTokenSnapshot? { + // Provider-specific by design: this fallback owns Grok's local session scan and publication. + let provider = UsageProvider.grok + let requestedHistoryDays = min(max(1, historyDays), GrokLocalSessionScanner.maximumLookbackDays) + if let task = self.grokLocalTokenScanTask { + return await task.value.map { self.narrowedGrokTokenSnapshot($0, historyDays: requestedHistoryDays) } + } + + 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.map { self.narrowedGrokTokenSnapshot($0, historyDays: requestedHistoryDays) } + } + 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. @@ -605,6 +659,28 @@ extension UsageStore { return nil } + func tokenSnapshotForLiveProviderConsumer( + fromProviderSnapshot snapshot: UsageSnapshot?, + provider: UsageProvider, + historyDays: Int? = nil) + -> CostUsageTokenSnapshot? + { + let projected = self.tokenSnapshot( + fromProviderSnapshot: snapshot, + provider: provider, + historyDays: historyDays) + // Provider-specific by design: Grok's remote probe may fail while its local session scan still + // publishes a newer valid estimate. Keep this selection scoped to live consumers so account + // override cards cannot inherit provider-level data from a different context. + guard provider == .grok, + let published = self.tokenSnapshotPublicationForCurrentProviderConfig(for: provider)?.snapshot + else { return projected } + let windowDays = historyDays ?? self.settings.costUsageHistoryDays + let narrowedPublished = self.narrowedGrokTokenSnapshot(published, historyDays: windowDays) + guard let projected else { return narrowedPublished } + return narrowedPublished.updatedAt > projected.updatedAt ? narrowedPublished : projected + } + nonisolated static func tokenCostNoDataMessage(for provider: UsageProvider) -> String { ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.noDataMessage() } diff --git a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift index f153d88af6..05d9a959b6 100644 --- a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift +++ b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift @@ -361,15 +361,8 @@ extension UsageStore { provider: UsageProvider) -> WidgetSnapshot.TokenUsageSummary? { guard let snapshot else { return nil } - let fallbackTokens: Int? = { - var sum = 0 - for t in snapshot.daily.compactMap(\.totalTokens) { - let (res, of) = sum.addingReportingOverflow(t) - if of { return nil } - sum = res - } - return sum > 0 ? sum : nil - }() + let fallbackTokens = CostUsageDailyReport.completeCountSum(snapshot.daily.map(\.totalTokens)) + .flatMap { $0 > 0 ? $0 : nil } let monthTokensValue = snapshot.last30DaysTokens ?? fallbackTokens let sessionLabel = if provider == .bedrock || provider == .mistral { "Latest billing day" diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 430875cbaa..9db3071cb9 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -179,6 +179,8 @@ final class UsageStore { @ObservationIgnored var claudeSwapTransientState = ClaudeSwapTransientState() var tokenSnapshotPublications: [ProviderInstanceID: TokenSnapshotPublication] = [:] var tokenSnapshotPublicationRevisions: [ProviderInstanceID: UInt64] = [:] + @ObservationIgnored var grokLocalTokenScanTask: Task? + @ObservationIgnored var grokLocalTokenScanToken: UUID? var spendDashboardTokenPublications: [ProviderInstanceID: TokenSnapshotPublication] = [:] var spendDashboardTokenPublicationRevisions: [ProviderInstanceID: UInt64] = [:] @ObservationIgnored var spendDashboardTokenIncorporatedTriggers: @@ -281,6 +283,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?, @@ -986,6 +990,7 @@ final class UsageStore { self.codexPlanHistoryBackfillTask?.cancel() self.resetBoundaryRefreshTask?.cancel() self.planUtilizationHistoryLoadTask?.cancel() + self.grokLocalTokenScanTask?.cancel() } enum SessionQuotaWindowSource: String { @@ -994,23 +999,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 { @@ -1617,6 +1605,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 b0ecaacea8..ce502fad09 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -189,6 +189,92 @@ 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.narrowedProvenance( + snapshot: self.costProvenance, + entries: entries, + includesMetered: days == self.historyDays && self.meteredCostUSD != nil), + 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 allEntriesCarryRequests = !entries.isEmpty && entries.allSatisfy { $0.requestCount != nil } + let totalRequests: Int? = if allEntriesCarryRequests { + CostUsageDailyReport.completeCountSum(entries.map(\.requestCount)) + } 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.narrowedProvenance( + snapshot: self.costProvenance, + entries: entries, + includesMetered: derived.meteredCostUSD != nil), + credentialScopeFingerprint: self.credentialScopeFingerprint, + daily: entries, + projects: self.projects, + sessions: self.sessions, + hourly: self.hourly, + updatedAt: self.updatedAt) + } + + /// A narrowed window can exclude every priced row it inherited its disclosure from, so the derived + /// snapshot must describe the rows it kept. This is the same narrowing the window summary applies; it + /// deliberately does not re-derive which *kind* of cost the surviving rows carry, because per-row + /// coverage counts mean different things to different providers. + private static func narrowedProvenance( + snapshot: CostProvenance, + entries: [CostUsageDailyReport.Entry], + includesMetered: Bool) -> CostProvenance + { + CostProvenance.forWindow( + snapshot: snapshot, + hasWindowCosts: entries.contains { $0.costUSD != nil }, + includesMetered: includesMetered) + } + public func summary(forLastDays requestedDays: Int, calendar: Calendar = .current) -> CostUsageWindowSummary { let days = max(1, requestedDays) let today = calendar.startOfDay(for: self.updatedAt) @@ -200,7 +286,6 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { return dayKey >= startKey && dayKey <= endKey } let costs = entries.compactMap(\.costUSD) - let tokens = entries.compactMap(\.totalTokens) let requests = entries.compactMap(\.requestCount) var mix = CostUsageTokenMix() var coverage = CostUsageCoverageAccumulator() @@ -210,7 +295,7 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { } let coversFullHistory = days >= self.historyDays let windowMetered = coversFullHistory ? self.meteredCostUSD : nil - let totalTokens = tokens.isEmpty ? nil : CheckedSum.integers(tokens) + let totalTokens = CostUsageDailyReport.completeCountSum(entries.map(\.totalTokens)) let totalRequests = requests.isEmpty ? nil : CheckedSum.integers(requests) return CostUsageWindowSummary( days: days, @@ -790,6 +875,16 @@ extension CostUsageDailyReport { } } + /// Counts are complete only when every contribution is valid and their sum is representable. + package static func completeCountSum(_ values: some Sequence) -> Int? { + var total = OptionalCountAccumulator() + for value in values { + guard let value, value >= 0 else { return nil } + total.add(value) + } + return total.value + } + struct OptionalCountAccumulator { private(set) var value: Int? private var overflowed = false diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 8ae8824120..cac5967f8d 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 = "6a4df886696f4ab5" + static let value = "b004d0cf7d471304" } diff --git a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift index c316a43d4c..3b4ba43c55 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift @@ -1,39 +1,80 @@ +import CoreFoundation import Foundation /// One local-calendar day of Grok session-token activity. public struct GrokLocalDailyBucket: Sendable, Equatable { public let date: String - public let totalTokens: Int + public let inputTokens: Int? + public let cacheReadTokens: Int? + public let cacheCreationTokens: Int? + public let outputTokens: Int? + public let reasoningTokens: Int? + public let totalTokens: Int? public let sessionCount: Int + public let requestCount: Int + public let costUSD: Double? 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 + public let totalTokens: Int? public let lastSessionAt: Date? public let primaryModel: String? public let models: [String] public let daily: [GrokLocalDailyBucket] public let scannedAt: Date + public let historyCoverageIsEstablished: Bool + /// Which source produced the daily costs: the CLI's recorded spend, the public card, or both. + public let costProvenance: CostProvenance public init( sessionCount: Int, - totalTokens: Int, + totalTokens: Int?, lastSessionAt: Date?, primaryModel: String?, models: [String], daily: [GrokLocalDailyBucket] = [], - scannedAt: Date = .init()) + scannedAt: Date = .init(), + historyCoverageIsEstablished: Bool = true, + costProvenance: CostProvenance = .listPriceEstimate) { self.sessionCount = sessionCount self.totalTokens = totalTokens @@ -42,136 +83,650 @@ public struct GrokLocalSessionSummary: Sendable { self.models = models self.daily = daily self.scannedAt = scannedAt + self.historyCoverageIsEstablished = historyCoverageIsEstablished + self.costProvenance = costProvenance } - /// Local session tokens only. SuperGrok credits are a quota, not dollars, so this never invents spend. + /// Local turns priced from the spend the Grok CLI recorded, falling back to public API list rates for + /// entries it did not record. Neither figure is a Grok bill. public func toCostUsageTokenSnapshot(historyDays: Int) -> CostUsageTokenSnapshot? { let entries = self.daily.map { bucket in CostUsageDailyReport.Entry( 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: self.costProvenance, daily: entries, updatedAt: self.scannedAt) } + + /// Grok counts recorded turns as priced and public-card fallbacks as estimated. Only a mixed + /// snapshot needs these counts to decide which sources survived a window or selected-day filter. + public static func costProvenance( + for daily: [CostUsageDailyReport.Entry], + fallback: CostProvenance) -> CostProvenance + { + guard daily.contains(where: { $0.costUSD != nil }) else { return .unknown } + guard fallback == .mixed else { return fallback } + var counts = CostUsageCoverageCounts() + for entry in daily { + counts.merge(entry.coverageCounts) + } + switch (counts.priced > 0, counts.estimated > 0) { + case (true, true): return .mixed + case (true, false): return .vendorMetered + case (false, true): return .listPriceEstimate + case (false, false): return fallback + } + } +} + +struct GrokLocalSessionParseCacheMetrics: Sendable, Equatable { + 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, + maximumDiscoveryEntries: 4096, + maximumTotalBytes: 256 * 1024 * 1024, + maximumTotalTurns: 100_000) + + let maximumFileBytes: Int64 + let maximumLineBytes: Int + let maximumTurnsPerFile: Int + let maximumSessions: Int + let maximumDiscoveryEntries: Int + let maximumTotalBytes: Int64 + let maximumTotalTurns: Int + + init( + maximumFileBytes: Int64, + maximumLineBytes: Int, + maximumTurnsPerFile: Int, + maximumSessions: Int = 256, + maximumDiscoveryEntries: Int = 4096, + 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.maximumDiscoveryEntries = max(1, maximumDiscoveryEntries) + 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, + maximumDiscoveryEntries: self.maximumDiscoveryEntries, + 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? + /// Spend the Grok CLI recorded for this usage, in ticks. `nil` when the record omits it or reports 0. + let costUsdTicks: Int? +} + +private struct GrokParsedTurn: Sendable { + 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 fileDecodeCountByPath: [String: Int] = [:] + private var jsonDecodeCountByPath: [String: Int] = [:] + 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 + let metricsPath = URL(fileURLWithPath: path).resolvingSymlinksInPath().path + self.fileDecodeCountByPath[metricsPath, default: 0] += 1 + self.jsonDecodeCountByPath[metricsPath, default: 0] += decoded.jsonDecodeCount + guard decoded.cacheable else { return decoded.batch } + if var entry = self.entries[path], entry.identity == identity { + entry.accessOrdinal = self.nextAccessOrdinal() + 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 metrics(pathPrefix: String) -> GrokLocalSessionParseCacheMetrics { + self.lock.lock() + defer { self.lock.unlock() } + let resolvedPrefix = URL(fileURLWithPath: pathPrefix).resolvingSymlinksInPath().path + let descendantPrefix = resolvedPrefix.hasSuffix("/") ? resolvedPrefix : "\(resolvedPrefix)/" + let belongsToPrefix: (String) -> Bool = { path in + path == resolvedPrefix || path.hasPrefix(descendantPrefix) + } + return GrokLocalSessionParseCacheMetrics( + fileDecodeCount: self.fileDecodeCountByPath + .filter { belongsToPrefix($0.key) } + .reduce(0) { $0 + $1.value }, + jsonDecodeCount: self.jsonDecodeCountByPath + .filter { belongsToPrefix($0.key) } + .reduce(0) { $0 + $1.value }) + } + + func reset() { + self.lock.lock() + defer { self.lock.unlock() } + self.entries.removeAll() + self.fileDecodeCount = 0 + self.jsonDecodeCount = 0 + self.fileDecodeCountByPath.removeAll() + self.jsonDecodeCountByPath.removeAll() + self.accessOrdinal = 0 + } + + 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 - /// Walk `~/.grok/sessions///signals.json` and aggregate stats. - public static func summarize( + 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: Int? = 0 + var cacheReadTokens: Int? = 0 + var cacheCreationTokens: Int? = 0 + var outputTokens: Int? = 0 + var reasoningTokens: Int? = 0 + var totalTokens: Int? = 0 + var requestCount = 0 + var costUSD = 0.0 + var hasPricedCost = false + var hasUnattributedCost = false + } + + private struct MutableDailyBucket { + var inputTokens: Int? = 0 + var cacheReadTokens: Int? = 0 + var cacheCreationTokens: Int? = 0 + var outputTokens: Int? = 0 + var reasoningTokens: Int? = 0 + var totalTokens: Int? = 0 + var requestCount = 0 + var sessionIDs: Set = [] + var modelCounts: [String: Int] = [:] + 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] = [:] + /// Whether any entry was priced from the CLI's recorded spend, and whether any fell back to the + /// public card. Both can be true, which publishes a mixed window rather than claiming either source. + var sawRecordedCost = false + var sawEstimatedCost = false + } + + /// Divisor that turns `costUsdTicks` into USD. + /// + /// Established on two independent corpora: 934 turns in #3345 and the 6-turn corpus behind this branch's + /// gated proof, both landing on `1e10` to four decimals. An earlier reading of this branch used `1e9`, + /// which happened to equal `1.7 x` the public card on a sample drawn entirely from an xAI promotional + /// window; the field carries that promotional rate, so reconstructing the same turns from the public card + /// overstated them by 5.9x. + static let costUsdTicksPerUSD = 1e10 + + private static let parseCache = GrokLocalSessionParseCache() + private static let turnCompletedNeedle = Data("turn_completed".utf8) + + /// 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()) -> GrokLocalSessionSummary + now: Date = .init()) async -> 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]) - else { + await self.summarizeRequestingPricingRefresh( + env: env, + lookbackDays: lookbackDays, + now: now, + modelsDevCacheRoot: nil) + { + await ModelsDevPricingPipeline.refreshIfNeeded(now: now) + } + } + + static func summarizeRequestingPricingRefresh( + env: [String: String], + 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() + } + let pricing = PricingContext( + modelsDevCatalog: nil, + modelsDevCacheRoot: modelsDevCacheRoot, + customPricing: .empty) + // A corpus scan is synchronous and can run for minutes. `CostUsageScanExecutor` exists to keep + // exactly that work off the cooperative pool, so this path must queue through it rather than + // calling the scanner inline and stalling menu work alongside other scans. + do { + return try await CostUsageScanExecutor.run { checkCancellation in + try checkCancellation() + let summary = Self.summarize( + env: env, + fileManager: .default, + lookbackDays: lookbackDays, + now: now, + pricing: pricing, + checkCancellation: checkCancellation) + try checkCancellation() + return summary + } + } catch { + // Callers rely on this fallback always returning a value. Report the window as + // unestablished so a cancelled scan is never presented as an authoritative zero. return GrokLocalSessionSummary( sessionCount: 0, totalTokens: 0, lastSessionAt: nil, primaryModel: nil, models: [], - scannedAt: now) + scannedAt: now, + historyCoverageIsEstablished: false, + costProvenance: .unknown) } + } + + /// 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, + checkCancellation: @escaping @Sendable () throws -> Void = { try Task.checkCancellation() }) + -> GrokLocalSessionSummary + { + self.summarize( + env: env, + fileManager: fileManager, + lookbackDays: lookbackDays, + now: now, + pricing: PricingContext( + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot, + customPricing: customPricing), + scanLimits: scanLimits, + checkCancellation: checkCancellation) + } + + 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, + checkCancellation: @escaping @Sendable () throws -> Void = { try Task.checkCancellation() }) + -> GrokLocalSessionSummary + { + let root = GrokCredentialsStore.grokHomeURL(env: env, fileManager: fileManager) + .appendingPathComponent("sessions", isDirectory: true) + var visitedCachePaths: Set = [] + defer { self.parseCache.retainEntries(at: visitedCachePaths) } let calendar = Calendar.current - let lookbackCutoff = calendar.date(byAdding: .day, value: -lookbackDays, to: now) ?? now + // Consumers window this history with `narrowed(toHistoryDays:)`, which is an inclusive local-day + // range ending today. Derive the same boundary here so the first displayed day is scanned whole + // and no extra partial day is collected for consumers to discard. + let startOfToday = calendar.startOfDay(for: now) + let lookbackCutoff = calendar.date( + byAdding: .day, + value: -max(0, lookbackDays - 1), + to: startOfToday) ?? startOfToday + guard let sessionSelection = self.recentSessionPaths( + root: root, + fileManager: fileManager, + lookbackCutoff: lookbackCutoff, + limits: scanLimits, + checkCancellation: checkCancellation) + else { + return self.emptySummary(now: now) + } + var sessionCount = 0 - var totalTokens = 0 var lastSessionAt: Date? - var modelCounts: [String: Int] = [:] - var dailyTokens: [String: Int] = [:] - var dailySessions: [String: Int] = [:] - var dailyModels: [String: [String: Int]] = [:] - - while let url = rootEnum.nextObject() as? URL { - guard url.lastPathComponent == "signals.json" else { continue } - let attrs = try? url.resourceValues(forKeys: [.contentModificationDateKey]) - let mtime = attrs?.contentModificationDate ?? Date.distantPast - guard mtime >= lookbackCutoff else { continue } - - guard let data = try? Data(contentsOf: url), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] - else { continue } - - sessionCount += 1 - let beforeCompaction = (json["totalTokensBeforeCompaction"] as? Int) ?? 0 - let contextUsed = (json["contextTokensUsed"] as? Int) ?? 0 - let sessionTokens = beforeCompaction + contextUsed - totalTokens += sessionTokens + var aggregation = ScanAggregation() + var remainingTotalBytes = scanLimits.maximumTotalBytes + var remainingTotalTurns = scanLimits.maximumTotalTurns + var historyCoverageIsEstablished = sessionSelection.historyCoverageIsEstablished - if mtime > (lastSessionAt ?? Date.distantPast) { - lastSessionAt = mtime + for sessionPath in sessionSelection.paths { + guard !self.cancellationRequested(checkCancellation) 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, + checkCancellation: checkCancellation) + } + guard !self.cancellationRequested(checkCancellation) 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 !self.cancellationRequested(checkCancellation) 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()) } + let totalTokens = buckets.reduce(Int?(0)) { self.addCounts($0, $1.totalTokens) } + historyCoverageIsEstablished = historyCoverageIsEstablished && totalTokens != nil + && buckets.allSatisfy { bucket in + [ + bucket.inputTokens, + bucket.outputTokens, + bucket.cacheReadTokens, + bucket.cacheCreationTokens, + bucket.reasoningTokens, + ].allSatisfy { $0 != nil } + } return GrokLocalSessionSummary( sessionCount: sessionCount, totalTokens: totalTokens, lastSessionAt: lastSessionAt, primaryModel: sortedModels.first, models: sortedModels, - daily: daily, - scannedAt: now) + daily: buckets, + scannedAt: now, + historyCoverageIsEstablished: historyCoverageIsEstablished, + costProvenance: Self.provenance(aggregation: aggregation)) + } + + /// A window that priced nothing has no provenance to claim; one that used both sources is mixed. + private static func provenance(aggregation: ScanAggregation) -> CostProvenance { + switch (aggregation.sawRecordedCost, aggregation.sawEstimatedCost) { + case (true, true): .mixed + case (true, false): .vendorMetered + case (false, true): .listPriceEstimate + case (false, false): .unknown + } } public static func summarizeOffMainThread( @@ -181,12 +736,615 @@ public enum GrokLocalSessionScanner { { try await CostUsageScanExecutor.run { checkCancellation in try checkCancellation() - let summary = Self.summarize(env: env, lookbackDays: lookbackDays, now: now) + let summary = Self.summarize( + env: env, + fileManager: .default, + lookbackDays: lookbackDays, + now: now, + pricing: PricingContext(modelsDevCatalog: nil, modelsDevCacheRoot: nil, customPricing: .empty), + checkCancellation: checkCancellation) try checkCancellation() return summary } } + static func parseCacheMetricsForTesting() -> GrokLocalSessionParseCacheMetrics { + self.parseCache.metrics() + } + + static func parseCacheMetricsForTesting(pathPrefix: String) -> GrokLocalSessionParseCacheMetrics { + self.parseCache.metrics(pathPrefix: pathPrefix) + } + + static func resetParseCacheForTesting() { + self.parseCache.reset() + } + + static func parseCacheEntryCountForTesting() -> Int { + self.parseCache.entryCount() + } + + static func parseCacheTurnCountForTesting() -> Int { + self.parseCache.cachedTurnCount() + } + + private static func cancellationRequested(_ checkCancellation: () throws -> Void) -> Bool { + do { + try checkCancellation() + return false + } catch { + return true + } + } + + private static func emptySummary(now: Date) -> GrokLocalSessionSummary { + GrokLocalSessionSummary( + sessionCount: 0, + totalTokens: 0, + lastSessionAt: nil, + primaryModel: nil, + models: [], + scannedAt: now, + costProvenance: .unknown) + } + + 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, + limits: GrokLocalSessionScanLimits, + checkCancellation: () throws -> Void) -> RecentSessionSelection? + { + guard let rootEnum = fileManager.enumerator( + at: root, + includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey, .isDirectoryKey], + options: [.skipsHiddenFiles]) + else { return nil } + + let maximumCount = limits.maximumSessions + let maximumDiscoveryEntries = limits.maximumDiscoveryEntries + var sessionModificationDates: [String: Date] = [:] + var historyCoverageIsEstablished = true + let trimThreshold = maximumCount > Int.max / 2 ? Int.max : maximumCount * 2 + var discoveryEntryCount = 0 + while discoveryEntryCount < maximumDiscoveryEntries, + let url = rootEnum.nextObject() as? URL + { + discoveryEntryCount += 1 + guard !self.cancellationRequested(checkCancellation) else { return nil } + let name = url.lastPathComponent + guard name == "updates.jsonl" || name == "signals.json" else { continue } + guard let identity = self.fileIdentity(for: url), + 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 discoveryEntryCount == maximumDiscoveryEntries { + // DirectoryEnumerator is lazy, so stopping here bounds both traversal and metadata I/O. Conservatively + // mark coverage incomplete at the boundary because there may be undiscovered sessions after this point. + historyCoverageIsEstablished = false + } + if sessionModificationDates.count > maximumCount { + historyCoverageIsEstablished = false + self.trimRecentSessions(&sessionModificationDates, maximumCount: maximumCount) + } + 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, + checkCancellation: @escaping @Sendable () throws -> Void) -> 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: checkCancellation, + 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 = self.tokenCount(object["inputTokens"]) + let outputTokens = self.tokenCount(object["outputTokens"]) + let reportedTotalTokens = self.tokenCount(object["totalTokens"]) + let totalTokens = reportedTotalTokens == 0 + ? self.addCounts(inputTokens, outputTokens) : reportedTotalTokens + return GrokParsedTokenUsage( + inputTokens: inputTokens, + outputTokens: outputTokens, + totalTokens: totalTokens, + cachedReadTokens: self.tokenCount(object["cachedReadTokens"]), + cacheCreationTokens: self.tokenCount(object["cacheCreationTokens"]), + reasoningTokens: self.tokenCount(object["reasoningTokens"]), + modelCalls: self.integer(object["modelCalls"]), + costUsdTicks: self.recordedCostTicks(object["costUsdTicks"])) + } + + /// Absent native token classes are zero; malformed or unrepresentable values remain unknown. + private static func tokenCount(_ value: Any?) -> Int? { + guard let value else { return 0 } + guard let count = self.integer(value), count >= 0 else { return nil } + return count + } + + /// Unknown values stay unknown after later valid contributions at every aggregation level. + private static func addCounts(_ lhs: Int?, _ rhs: Int?) -> Int? { + guard let lhs, let rhs else { return nil } + let (sum, overflow) = lhs.addingReportingOverflow(rhs) + return overflow ? nil : sum + } + + /// `costUsdTicks` is the spend the CLI recorded for a turn, already carrying its price tier and any + /// promotional rate. A record that omits the field, or reports 0 as a small share of turns do, has no + /// recorded spend; those entries fall back to the public card. + private static func recordedCostTicks(_ value: Any?) -> Int? { + guard let ticks = self.integer(value), ticks > 0 else { return nil } + return ticks + } + + private static func integer(_ value: Any?) -> Int? { + guard let number = value as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID() + else { return nil } + // NSNumber's Int bridge may clamp oversized values. Preserve exact integer payloads first. + if let integer = Int(number.stringValue) { return integer } + return Int(exactly: number.doubleValue) + } + + private static func readSignalsMetadata(at url: URL, maximumBytes: Int) -> [String]? { + 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 + } +} + +// MARK: - Daily aggregation and pricing + +extension GrokLocalSessionScanner { + 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 = self.addCounts(bucket.inputTokens, turn.usage.inputTokens) + bucket.cacheReadTokens = self.addCounts(bucket.cacheReadTokens, turn.usage.cachedReadTokens) + bucket.cacheCreationTokens = self.addCounts(bucket.cacheCreationTokens, turn.usage.cacheCreationTokens) + bucket.outputTokens = self.addCounts(bucket.outputTokens, turn.usage.outputTokens) + bucket.reasoningTokens = self.addCounts(bucket.reasoningTokens, turn.usage.reasoningTokens) + bucket.totalTokens = self.addCounts(bucket.totalTokens, turn.usage.totalTokens) + bucket.sessionIDs.insert(sessionPath) + + // The outer tick is the authoritative turn total, including when model usage is populated. + let recordedTurnCost = turn.usage.costUsdTicks.map { Double($0) / Self.costUsdTicksPerUSD } + let modelCostsMatchTurn = self.recordedModelCostsMatchTurn(turn) + if let recordedTurnCost { + bucket.costUSD += recordedTurnCost + bucket.hasPricedCost = true + aggregation.sawRecordedCost = true + } + if turn.modelUsage.isEmpty { + let requests = self.requestCount(for: turn.usage) + bucket.requestCount += requests + if recordedTurnCost == nil { + 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 = self.addCounts(breakdown.inputTokens, usage.inputTokens) + breakdown.cacheReadTokens = self.addCounts(breakdown.cacheReadTokens, usage.cachedReadTokens) + breakdown.cacheCreationTokens = self.addCounts(breakdown.cacheCreationTokens, usage.cacheCreationTokens) + breakdown.outputTokens = self.addCounts(breakdown.outputTokens, usage.outputTokens) + breakdown.reasoningTokens = self.addCounts(breakdown.reasoningTokens, usage.reasoningTokens) + breakdown.totalTokens = self.addCounts(breakdown.totalTokens, usage.totalTokens) + breakdown.requestCount += requests + + if recordedTurnCost != nil { + // Keep model dollars only when the complete breakdown agrees with the paid turn total. + // The outer total was already counted, so nested ticks never add to it again. + if modelCostsMatchTurn, let recorded = usage.costUsdTicks { + breakdown.costUSD += Double(recorded) / Self.costUsdTicksPerUSD + breakdown.hasPricedCost = true + } else { + breakdown.hasUnattributedCost = true + } + } else if let recorded = usage.costUsdTicks { + let cost = Double(recorded) / Self.costUsdTicksPerUSD + breakdown.costUSD += cost + breakdown.hasPricedCost = true + bucket.costUSD += cost + bucket.hasPricedCost = true + aggregation.sawRecordedCost = true + } else 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 + bucket.estimatedRequestCount += requests + aggregation.sawEstimatedCost = true + } else { + bucket.unpricedRequestCount += requests + } + bucket.modelBreakdowns[sku] = breakdown + } + aggregation.daily[day] = bucket + } + + private static func recordedModelCostsMatchTurn(_ turn: GrokParsedTurn) -> Bool { + guard let total = turn.usage.costUsdTicks, !turn.modelUsage.isEmpty else { return false } + var modelTotal = 0 + for usage in turn.modelUsage.values { + guard let recorded = usage.costUsdTicks else { return false } + let (sum, overflow) = modelTotal.addingReportingOverflow(recorded) + guard !overflow else { return false } + modelTotal = sum + } + return modelTotal == total + } + + private static func costUSD( + sku: String, + usage: GrokParsedTokenUsage, + pricingDate: Date, + pricing: PricingContext) -> Double? + { + guard let inputTokens = usage.inputTokens, + let outputTokens = usage.outputTokens, + let cachedReadTokens = usage.cachedReadTokens, + let cacheCreationTokens = usage.cacheCreationTokens + else { return nil } + let model = "xai/\(sku)" + guard let resolvedPricing = CostUsagePricing.resolvedCodexPricing( + model: model, + pricingDate: pricingDate, + modelsDevCatalog: pricing.modelsDevCatalog, + modelsDevCacheRoot: pricing.modelsDevCacheRoot) + else { return nil } + + guard let callCount = self.validatedModelCallCount(for: usage) else { + if let threshold = resolvedPricing.thresholdTokens, + inputTokens > threshold + { + return nil + } + return CostUsagePricing.codexCostUSD( + pricing: resolvedPricing, + inputTokens: inputTokens, + cachedInputTokens: cachedReadTokens, + cacheWriteInputTokens: cacheCreationTokens, + outputTokens: outputTokens) + } + + // Without recorded turn or model spend, even splitting approximates public list prices. + // Context can grow within a turn, so mean per-call inputs can under-tier later calls. + return self.syntheticCallGroups( + usage: usage, + callCount: callCount, + 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, + let inputTokens = usage.inputTokens, + modelCalls > 0, + modelCalls <= 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] + { + guard let inputTokens = usage.inputTokens, + let outputTokens = usage.outputTokens, + let cachedReadTokens = usage.cachedReadTokens, + let cacheCreationTokens = usage.cacheCreationTokens + else { return [] } + let largerInputCallCount = inputTokens % callCount + let baseInput = inputTokens / callCount + let threshold = pricing.thresholdTokens + var ranges: [(range: Range, isLongContext: Bool)] = [] + if largerInputCallCount > 0 { + ranges.append(( + 0.. $0 } ?? false)) + } + if largerInputCallCount < callCount { + ranges.append(( + largerInputCallCount.. $0 } ?? false)) + } + return ranges.map { group in + let effectiveInput = self.effectiveInputTotals( + inputTokens: inputTokens, + cachedReadTokens: cachedReadTokens, + cacheCreationTokens: cacheCreationTokens, + callCount: callCount, + range: group.range) + return SyntheticCallGroup( + inputTokens: effectiveInput.input, + cachedReadTokens: effectiveInput.cachedRead, + cacheCreationTokens: effectiveInput.cacheCreation, + outputTokens: self.distributedTotal( + 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.hasUnattributedCost ? 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 ec8604daf3..6bde9857d9 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift @@ -87,9 +87,13 @@ 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 use the spend the CLI " + + "recorded, or public list prices where it recorded none. Neither is a bill." + }, + menuHintLines: [.estimate], + showsHintInProviderDetails: true, + estimateDisclaimer: "Grok CLI-recorded spend, list price where unrecorded · not a bill.", + chartEstimateDisclaimer: .estimate), pace: ProviderPaceCapability( resetWindowPace: .custom { window, now in guard Self.primaryLabel(window: window, now: now) == "Weekly", @@ -417,7 +421,9 @@ struct GrokWebFetchStrategy: ProviderFetchStrategy { } var localSummary: @Sendable ([String: String]) async throws -> GrokLocalSessionSummary? = { - try await GrokLocalSessionScanner.summarizeOffMainThread(env: $0) + await GrokLocalSessionScanner.summarizeRequestingPricingRefresh( + env: $0, + lookbackDays: GrokLocalSessionScanner.maximumLookbackDays) } var cliVersion: @Sendable ([String: String]) -> String? = { GrokStatusProbe.detectVersion(env: $0) } diff --git a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift index 31a3bdaa7c..387c84722e 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) } @@ -80,7 +80,9 @@ public struct GrokStatusProbe: Sendable { "Grok usage is unavailable because its billing sources did not report a usage percentage." var localSummary: @Sendable ([String: String]) async throws -> GrokLocalSessionSummary? = { - try await GrokLocalSessionScanner.summarizeOffMainThread(env: $0) + await GrokLocalSessionScanner.summarizeRequestingPricingRefresh( + env: $0, + lookbackDays: GrokLocalSessionScanner.maximumLookbackDays) } var settingsTransport: any ProviderHTTPTransport = ProviderHTTPClient.shared diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+CodexResolver.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+CodexResolver.swift index 0cc721aa3e..226b7bffa9 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+CodexResolver.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+CodexResolver.swift @@ -1,6 +1,10 @@ import Foundation extension CostUsagePricing { + /// Routes that may price a Codex-compatible model: the Codex fingerprint set plus the separately scoped xAI rates. + static let codexCompatibleModelsDevProviderIDs: Set = CostUsagePricing.codexModelsDevProviderIDs + .union(CostUsagePricing.xaiModelsDevProviderIDs) + /// One synchronous report collection owns one immutable catalog and bounded exact-input memos. /// Dates, token thresholds, custom overlays and priority multipliers stay in the scalar pricing path. final class CodexResolver { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 80b5b08a21..563be0ecb4 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -464,11 +464,7 @@ enum CostUsagePricing { ] static let codexModelsDevProviderID = "openai" - /// Provider IDs emitted by Codex-compatible clients that have matching entries in models.dev. - /// - /// The route prefix is part of the model identity for local usage estimates. Keep both the - /// client-facing aliases and their models.dev provider IDs here so pricing-cache fingerprints - /// invalidate when any supported route's rates change. + /// Provider IDs whose rates contribute to Codex pricing-cache fingerprints. static let codexModelsDevProviderIDs: Set = [ "deepseek", "kimi-coding", @@ -478,6 +474,9 @@ enum CostUsagePricing { "opencode-free", "opencode-go", ] + /// xAI rates price native Grok session summaries, not Codex subscription history. Keep their fingerprint scope + /// separate so an xAI catalog update cannot invalidate the unrelated Codex session cache. + static let xaiModelsDevProviderIDs: Set = ["xai"] private static let claudeModelsDevProviderID = "anthropic" /// Returns the provider/model identities that may price a Codex model. Keep this mapping @@ -490,11 +489,20 @@ enum CostUsagePricing { let routeID = String(trimmed[.. "grok-".count + "-build".count + { + targets.append((routeID, String(modelID.dropLast("-build".count)))) + } 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 91184d0063..ec4930c597 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift @@ -80,6 +80,7 @@ actor CostUsageStore { parserHash: CodexParserHash.value) static let cacheGeneration = "sqlite:\(CostUsageStore.schemaVersion)" static let compatiblePredecessorParserHashes: Set = [ + "6a4df886696f4ab5", // Current main; Grok pricing preserves native rows and parser-revision migration. "6d48baf0ed980828", // Source-backed row recovery preserves native history and scan checkpoints. "c2ac37e84074d2b2", // Native rows are unchanged by Claude completion metadata. "710f475c3d1cfb61", // 0.60.4 native rows and checkpoints are unchanged by Claude pricing corrections. @@ -92,6 +93,10 @@ actor CostUsageStore { "9ca89383b9957b07", // Warm refresh cursor retention preserves native rows, checkpoints, and reports. "9547dc9d7b7675f6", // Report lookup memos preserve native usage rows and checkpoints. "ba2eca901de4c53d", // Shared report accumulation preserves native usage rows and checkpoints. + "1a4afd74939160fd", // Previous Grok branch; report refactors preserve native rows and checkpoints. + "0bd6588c70196700", // Previous Grok branch; atomic catalog replacement preserves parsed rows and checkpoints. + "d2e66225d0b33672", // 0.56.6 main; Grok pricing additions preserve native rows and checkpoints. + "b974e5782bad3f29", // Previous Grok branch; native persisted rows remain compatible. "2590d36e1cc4a2ea", // Lazy token history reads preserve persisted rows and scan checkpoints. "edd0a6ad56c0e4e7", // Astra pricing changes report costs without changing native rows or scan checkpoints. "f043ae98075c8e4d", // Retained scan-range scheduling preserves native rows, checkpoints, and reports. diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexRouteDispatcher.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexRouteDispatcher.swift index ef35a7c1f8..71dddd6850 100644 --- a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexRouteDispatcher.swift +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexRouteDispatcher.swift @@ -9,24 +9,30 @@ 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": + // Legacy rows and API-key traffic have no subscription attribution. Only the + // per-attempt fan-out accepts explicit Grok OAuth provenance. + return .tokenOnly case "opencode-go": - .subscription(.opencodego) + return .subscription(.opencodego) case "kimi-coding", "kimi-for-coding": - .subscription(.kimi) + return .subscription(.kimi) case "deepseek": - .subscription(.deepseek) + return .subscription(.deepseek) case "opencode-free", "opencode": - .tokenOnly + return .tokenOnly default: - .unknown + return .unknown } } public static func route(modelName: String) -> OpenCodexRouteTarget { let trimmed = modelName.trimmingCharacters(in: .whitespacesAndNewlines) + // Provider-specific by design: OpenCodex bare model selectors default to the Codex subscription. guard let slash = trimmed.firstIndex(of: "/") else { return .subscription(.codex) } @@ -36,6 +42,7 @@ public enum OpenCodexRouteDispatcher { } public static func countsTowardCodexSubscription(modelName: String) -> Bool { + // Provider-specific by design: this public predicate filters explicitly for the Codex subscription. if case .subscription(.codex) = self.route(modelName: modelName) { return true } @@ -45,8 +52,8 @@ public enum OpenCodexRouteDispatcher { public static func route(provider: String, modelName: String) -> OpenCodexRouteTarget { let provider = provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let trimmedModel = modelName.trimmingCharacters(in: .whitespacesAndNewlines) - // Only legacy openai transport labels delegate attribution to an explicit route prefix. // A model such as openai/gpt-5.4 served by OpenRouter does not consume a Codex subscription. + // Provider-specific by design: only legacy openai transport labels delegate to a route prefix. if provider == "openai", trimmedModel.contains("/") { let modelRoute = self.route(modelName: trimmedModel) if modelRoute != .unknown { diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift index 0851d95876..87e56d0200 100644 --- a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift @@ -186,20 +186,21 @@ enum OpenCodexUsageAggregator { { day.mix.merge(entry.usage?.tokenMix ?? .init()) day.tokens.merge(entry.resolvedTotalCount) - day.priced += entry.usageStatus == .reported ? 1 : 0 - day.estimated += entry.usageStatus == .estimated ? 1 : 0 + let usesGrokEstimate = entry.credentialSource == .grokOAuth + day.priced += entry.usageStatus == .reported && !usesGrokEstimate ? 1 : 0 + day.estimated += entry.usageStatus == .estimated || usesGrokEstimate ? 1 : 0 day.unmetered += entry.usageStatus == .unsupported ? 1 : 0 day.unpriced += entry.usageStatus == .unreported ? 1 : 0 if let cost { day.cost += cost day.sawCost = true - } else if entry.usageStatus == .reported { + } else if entry.usageStatus == .reported && !usesGrokEstimate { day.unpriced += 1 if day.priced > 0 { day.priced -= 1 } - } else if entry.usageStatus == .estimated { + } else if entry.usageStatus == .estimated || usesGrokEstimate { day.unpriced += 1 if day.estimated > 0 { day.estimated -= 1 @@ -323,6 +324,14 @@ enum OpenCodexUsageAggregator { cacheReadTokens: cacheRead, cacheWriteTokens: cacheWrite) } + // Provider-specific by design: raw xAI rows need an explicit price to establish a standalone estimate. + // Subscription attribution remains gated separately by physical Grok OAuth attempts in the fan-out. + let isXAI = pricingProvider == "xai" || entry.model.lowercased().hasPrefix("xai/") + if isXAI, entry.credentialSource != .grokOAuth, + customPricingOverlay.rates(providerID: pricingProvider, model: entry.model) == nil + { + return nil + } return CostUsagePricing.providerCostUSD( providerID: pricingProvider, model: entry.model, diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageFanOut.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageFanOut.swift index a060ff8df6..05de85c73d 100644 --- a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageFanOut.swift +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageFanOut.swift @@ -9,7 +9,14 @@ public enum OpenCodexUsageFanOut { customPricing: CostUsageCustomPricing = .empty) -> [UsageProvider: CostUsageTokenSnapshot] { var grouped: [UsageProvider: [OpenCodexUsageEntry]] = [:] - for entry in entries { + let latest = Dictionary(entries.map { ($0.requestID, $0) }, uniquingKeysWith: { _, latest in latest }) + for entry in latest.values { + let grokAttempts = self.grokOAuthEntries(from: entry) + if !grokAttempts.isEmpty { + // Provider-specific by design: each OAuth attempt contributes only its own reported Grok usage. + grouped[.grok, default: []].append(contentsOf: grokAttempts) + } + if entry.provider == "xai" || entry.provider == "combo" { continue } guard case let .subscription(provider) = OpenCodexRouteDispatcher.route( provider: entry.provider, modelName: entry.model) @@ -36,12 +43,29 @@ public enum OpenCodexUsageFanOut { } } + static func grokOAuthEntries(from entry: OpenCodexUsageEntry) -> [OpenCodexUsageEntry] { + // Provider-specific by design: only physical xAI attempts from xAI or combo parents can prove Grok usage. + guard entry.provider == "xai" || entry.provider == "combo" else { return [] } + // Duplicate ordinals are ambiguous. Drop every copy rather than selecting whichever came first. + let ordinals = Dictionary(grouping: entry.attempts, by: \.ordinal) + return entry.attempts.compactMap { attempt in + guard ordinals[attempt.ordinal]?.count == 1, + attempt.provider == "xai", attempt.credentialSource == .grokOAuth, + attempt.sendCount > 0, !attempt.locallyAnswered, attempt.usageStatus == .reported, + attempt.usage != nil, + !attempt.model.contains("/") || attempt.model.hasPrefix("xai/") + else { return nil } + return OpenCodexUsageEntry(parent: entry, attempt: attempt) + } + } + public static func mergeSnapshots( _ base: CostUsageTokenSnapshot, _ supplement: CostUsageTokenSnapshot, now: Date, historyDays: Int, - calendar: Calendar) -> CostUsageTokenSnapshot + calendar: Calendar, + provider: UsageProvider? = nil) -> CostUsageTokenSnapshot { let mergedReport = CostUsageDailyReport.merged([ CostUsageDailyReport(data: base.daily, summary: nil), @@ -64,6 +88,10 @@ public enum OpenCodexUsageFanOut { calendar: calendar, historyCoverageIsEstablished: base.historyCoverageIsEstablished && supplement.historyCoverageIsEstablished, + // Provider-specific by design: Grok recorded and OpenCodex estimated rows retain distinct coverage. + costProvenance: provider == .grok + ? GrokLocalSessionSummary.costProvenance(for: mergedReport.data, fallback: .mixed) + : .unknown, projects: projects, sessions: sessions, updatedAt: max(base.updatedAt, supplement.updatedAt)) diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageModels.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageModels.swift index a4c277388d..9b5e6cfdf3 100644 --- a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageModels.swift +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageModels.swift @@ -7,7 +7,7 @@ public enum OpenCodexUsageStatus: String, Sendable, Equatable, Codable { case unsupported } -public struct OpenCodexTokenUsage: Sendable, Equatable { +public struct OpenCodexTokenUsage: Sendable, Equatable, Codable { public var inputTokens: Int? public var outputTokens: Int? public var cachedInputTokens: Int? @@ -66,6 +66,23 @@ public struct OpenCodexTokenUsage: Sendable, Equatable { } } +public enum OpenCodexUsageCredentialSource: String, Sendable, Equatable, Codable { + case grokOAuth = "grok-oauth" + case xaiAPIKey = "xai-api-key" +} + +public struct OpenCodexUsageAttempt: Sendable, Equatable, Codable { + public let ordinal: Int + public let provider: String + public let model: String + public let credentialSource: OpenCodexUsageCredentialSource? + public let usageStatus: OpenCodexUsageStatus + public let sendCount: Int + public let locallyAnswered: Bool + public let usage: OpenCodexTokenUsage? + public let totalTokens: Int? +} + public struct OpenCodexUsageEntry: Sendable, Equatable { public let requestID: String public let timestamp: Date @@ -77,6 +94,9 @@ public struct OpenCodexUsageEntry: Sendable, Equatable { public let conversationID: String? public let usage: OpenCodexTokenUsage? public let totalTokens: Int? + public let attempts: [OpenCodexUsageAttempt] + /// Set only on a derived physical-attempt entry, never from top-level JSON metadata. + let credentialSource: OpenCodexUsageCredentialSource? public init( requestID: String, @@ -88,8 +108,11 @@ public struct OpenCodexUsageEntry: Sendable, Equatable { surface: String? = nil, conversationID: String? = nil, usage: OpenCodexTokenUsage? = nil, - totalTokens: Int? = nil) + totalTokens: Int? = nil, + attempts: [OpenCodexUsageAttempt] = []) { + self.credentialSource = nil + self.attempts = attempts self.requestID = requestID self.timestamp = timestamp self.provider = provider @@ -102,6 +125,22 @@ public struct OpenCodexUsageEntry: Sendable, Equatable { self.totalTokens = totalTokens } + init(parent: OpenCodexUsageEntry, attempt: OpenCodexUsageAttempt) { + // A length prefix avoids collisions between request IDs containing separators. + self.requestID = "ocx-attempt:\(parent.requestID.utf8.count):\(parent.requestID):\(attempt.ordinal)" + self.timestamp = parent.timestamp + self.provider = attempt.provider + self.model = attempt.model + self.usageStatus = attempt.usageStatus + self.accountLogLabel = nil + self.surface = parent.surface + self.conversationID = parent.conversationID + self.usage = attempt.usage + self.totalTokens = attempt.totalTokens + self.attempts = [] + self.credentialSource = attempt.credentialSource + } + public var resolvedTotalTokens: Int? { self.resolvedTotalCount.value } diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageParser.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageParser.swift index c024338096..9446461d95 100644 --- a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageParser.swift +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageParser.swift @@ -1,3 +1,4 @@ +import CoreFoundation #if canImport(Darwin) import Darwin #elseif canImport(Glibc) @@ -270,7 +271,30 @@ public enum OpenCodexUsageParser { surface: self.nonEmptyString(object["surface"]), conversationID: self.nonEmptyString(object["conversationId"]), usage: usage, - totalTokens: self.nonnegativeInt(object["totalTokens"])) + totalTokens: self.nonnegativeInt(object["totalTokens"]), + attempts: self.attempts(object["attempts"])) + } + + private static func attempts(_ value: Any?) -> [OpenCodexUsageAttempt] { + guard let rows = value as? [[String: Any]] else { return [] } + return rows.compactMap { row in + guard let ordinal = self.nonnegativeInt(row["ordinal"], truncateFractional: false), ordinal > 0, + let provider = self.nonEmptyString(row["provider"]), + let model = self.nonEmptyString(row["model"]), + let sendCount = self.nonnegativeInt(row["sendCount"], truncateFractional: false) + else { return nil } + return OpenCodexUsageAttempt( + ordinal: ordinal, + provider: provider, + model: model, + credentialSource: (row["credentialSource"] as? String) + .flatMap(OpenCodexUsageCredentialSource.init(rawValue:)), + usageStatus: self.usageStatus(row["usageStatus"]), + sendCount: sendCount, + locallyAnswered: row["locallyAnswered"] as? Bool ?? false, + usage: self.usage(row["usage"]), + totalTokens: self.nonnegativeInt(row["totalTokens"])) + } } private static func usageStatus(_ value: Any?) -> OpenCodexUsageStatus { @@ -338,13 +362,16 @@ public enum OpenCodexUsageParser { return nil } - private static func nonnegativeInt(_ value: Any?) -> Int? { - guard let number = value as? NSNumber else { return nil } + private static func nonnegativeInt(_ value: Any?, truncateFractional: Bool = true) -> Int? { + guard let number = value as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID() + else { return nil } // Preserve exact integer payloads without trusting NSNumber's clamping `as? Int` bridge. if let integer = Int(number.stringValue) { return integer >= 0 ? integer : nil } - guard let integer = Int(exactly: number.doubleValue.rounded(.towardZero)) else { return nil } + let value = truncateFractional ? number.doubleValue.rounded(.towardZero) : number.doubleValue + guard let integer = Int(exactly: value) else { return nil } return integer >= 0 ? integer : nil } diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageStore.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageStore.swift index 7b7dcafb67..479202a033 100644 --- a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageStore.swift +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageStore.swift @@ -22,7 +22,7 @@ public struct OpenCodexUsageStore: Sendable { /// Schema v2 lives in a versioned filename so a v1 build keeps using `opencodex-usage.sqlite`. /// Leave that older file alone; do not delete it. public static let databaseFilename = "opencodex-usage-v2.sqlite" - private static let schemaVersion = 2 + private static let schemaVersion = 3 private static let cursorMetaKey = "parseCursor" private static let prefixDigestByteLimit = 64 * 1024 @@ -254,7 +254,7 @@ public struct OpenCodexUsageStore: Sendable { let sql = """ SELECT request_id, timestamp, provider, model, usage_status, account_label, surface, conversation_id, \ input_tokens, output_tokens, cached_input_tokens, cache_read_input_tokens, \ - cache_creation_input_tokens, reasoning_output_tokens, usage_total_tokens, total_tokens + cache_creation_input_tokens, reasoning_output_tokens, usage_total_tokens, total_tokens, attempts_json FROM entries """ guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return nil } @@ -275,6 +275,10 @@ public struct OpenCodexUsageStore: Sendable { cacheCreationInputTokens: Self.int(statement, 12), reasoningOutputTokens: Self.int(statement, 13), totalTokens: Self.int(statement, 14)) + guard let attemptsJSON = Self.text(statement, 16), + let attemptsData = attemptsJSON.data(using: .utf8), + let attempts = try? JSONDecoder().decode([OpenCodexUsageAttempt].self, from: attemptsData) + else { return nil } entries.append(OpenCodexUsageEntry( requestID: requestID, timestamp: Date(timeIntervalSince1970: sqlite3_column_double(statement, 1)), @@ -285,7 +289,8 @@ public struct OpenCodexUsageStore: Sendable { surface: Self.text(statement, 6), conversationID: Self.text(statement, 7), usage: usage, - totalTokens: Self.int(statement, 15))) + totalTokens: Self.int(statement, 15), + attempts: attempts)) } step = sqlite3_step(statement) } @@ -428,7 +433,8 @@ public struct OpenCodexUsageStore: Sendable { cache_creation_input_tokens INTEGER, reasoning_output_tokens INTEGER, usage_total_tokens INTEGER, - total_tokens INTEGER + total_tokens INTEGER, + attempts_json TEXT NOT NULL ); """ guard sqlite3_exec(db, sql, nil, nil, nil) == SQLITE_OK else { return } @@ -441,8 +447,8 @@ public struct OpenCodexUsageStore: Sendable { INSERT OR REPLACE INTO entries( request_id, timestamp, provider, model, usage_status, account_label, surface, conversation_id, input_tokens, output_tokens, cached_input_tokens, cache_read_input_tokens, - cache_creation_input_tokens, reasoning_output_tokens, usage_total_tokens, total_tokens - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + cache_creation_input_tokens, reasoning_output_tokens, usage_total_tokens, total_tokens, attempts_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """ guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return false } defer { sqlite3_finalize(statement) } @@ -465,6 +471,10 @@ public struct OpenCodexUsageStore: Sendable { Self.bind(statement, 14, entry.usage?.reasoningOutputTokens) Self.bind(statement, 15, entry.usage?.totalTokens) Self.bind(statement, 16, entry.totalTokens) + guard let attemptsData = try? JSONEncoder().encode(entry.attempts), + let attemptsJSON = String(data: attemptsData, encoding: .utf8) + else { return false } + Self.bind(statement, 17, attemptsJSON) guard sqlite3_step(statement) == SQLITE_DONE else { return false } } return true diff --git a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift index 172cfa2c84..a606d1576f 100644 --- a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift +++ b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift @@ -257,6 +257,9 @@ struct CostHistoryChartMenuViewTests { #expect( CostHistoryChartMenuView.estimateDisclaimer(provider: .codex) == "Estimated from token usage · not a subscription bill") + #expect( + CostHistoryChartMenuView.estimateDisclaimer(provider: .grok) + == "Grok CLI-recorded spend, list price where unrecorded · not a bill.") #expect(CostHistoryChartMenuView.estimateDisclaimer(provider: .claude) == nil) } diff --git a/Tests/CodexBarTests/CostUsageScanExecutorTests.swift b/Tests/CodexBarTests/CostUsageScanExecutorTests.swift index 807226aab3..2c8c40f24b 100644 --- a/Tests/CodexBarTests/CostUsageScanExecutorTests.swift +++ b/Tests/CodexBarTests/CostUsageScanExecutorTests.swift @@ -2,7 +2,7 @@ import Foundation import Testing @testable import CodexBarCore -struct CostUsageScanExecutorTests { +struct CostUsageScanExecutorTests: GrokLocalSessionScannerTestSupport { @Test func `runs work on the dedicated scan queue and returns its value`() async throws { let queue = self.makeQueue() @@ -137,6 +137,57 @@ struct CostUsageScanExecutorTests { _ = try? await blocker.value } + @Test + func `Grok JSONL parsing observes executor cancellation after scanning starts`() async throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let now = Date() + let turn = self.turn(timestamp: now, usage: self.singleModelUsage(input: 3, output: 2)) + let line = try #require(String(data: JSONSerialization.data(withJSONObject: turn), encoding: .utf8)) + "\n" + let lineCount = 40000 + try Data(String(repeating: line, count: lineCount).utf8) + .write(to: fixture.session.appendingPathComponent("updates.jsonl")) + let queue = self.makeQueue() + let checks = LockedValue(0) + let parsingStarted = LockedValue(false) + let releaseParser = LockedValue(false) + let root = fixture.root + let task = Task { + try await CostUsageScanExecutor.run(on: queue) { checkCancellation in + try GrokLocalSessionScanner.summarize( + env: ["GROK_HOME": root.path], + now: now, + modelsDevCatalog: Self.catalog(), + checkCancellation: { + checks.update { $0 += 1 } + // One session has fewer than 10 discovery checks; this barrier is inside chunk parsing. + if checks.value == 32 { + parsingStarted.set(true) + while !releaseParser.value { + Thread.sleep(forTimeInterval: 0.001) + } + } + try checkCancellation() + }) + } + } + let reachedParser = await self.waitUntil(timeout: .seconds(3)) { parsingStarted.value } + #expect(reachedParser) + let cancelledAt = ContinuousClock.now + task.cancel() + releaseParser.set(true) + await #expect(throws: CancellationError.self) { try await task.value } + let nextScanRan = try await CostUsageScanExecutor.run(on: queue) { _ in true } + let cancellationLatency = cancelledAt.duration(to: .now) + #expect(nextScanRan) + #expect(cancellationLatency < .seconds(1)) + let decoded = GrokLocalSessionScanner.parseCacheMetricsForTesting(pathPrefix: root.path).jsonDecodeCount + #expect(decoded > 0) + #expect(decoded < lineCount) + print("grok_cancel_decoded_lines=\(decoded)/\(lineCount)") + print("grok_cancel_queue_release=\(cancellationLatency)") + } + private func makeQueue() -> DispatchQueue { DispatchQueue(label: "\(CostUsageScanExecutor.queueLabel).tests.\(UUID().uuidString)") } diff --git a/Tests/CodexBarTests/CostUsageStoreTests.swift b/Tests/CodexBarTests/CostUsageStoreTests.swift index 3f4d7cd879..6bb2ce74de 100644 --- a/Tests/CodexBarTests/CostUsageStoreTests.swift +++ b/Tests/CodexBarTests/CostUsageStoreTests.swift @@ -1023,6 +1023,7 @@ extension CostUsageStoreTests { extension CostUsageStoreTests { @Test(arguments: [ + "6a4df886696f4ab5", "6d48baf0ed980828", // Released in 0.60.5. "c2ac37e84074d2b2", "710f475c3d1cfb61", // Released in 0.60.4. @@ -1033,8 +1034,10 @@ extension CostUsageStoreTests { "ca4bc3875600536f", "7f00691fa96c78d1", "9ca89383b9957b07", + "1a4afd74939160fd", "ba2eca901de4c53d", - "9547dc9d7b7675f6", // Released in 0.56.7. + "9547dc9d7b7675f6", + "0bd6588c70196700", "2590d36e1cc4a2ea", "edd0a6ad56c0e4e7", "f043ae98075c8e4d", @@ -1057,6 +1060,7 @@ extension CostUsageStoreTests { let fixture = try StoreFixture() defer { fixture.remove() } #expect(CostUsageStore.compatiblePredecessorParserHashes == [ + "6a4df886696f4ab5", "6d48baf0ed980828", "c2ac37e84074d2b2", "710f475c3d1cfb61", @@ -1067,8 +1071,12 @@ extension CostUsageStoreTests { "ca4bc3875600536f", "7f00691fa96c78d1", "9ca89383b9957b07", + "1a4afd74939160fd", "ba2eca901de4c53d", "9547dc9d7b7675f6", + "0bd6588c70196700", + "d2e66225d0b33672", + "b974e5782bad3f29", "2590d36e1cc4a2ea", "edd0a6ad56c0e4e7", "f043ae98075c8e4d", diff --git a/Tests/CodexBarTests/Fixtures/GrokOpenCodex/usage.jsonl b/Tests/CodexBarTests/Fixtures/GrokOpenCodex/usage.jsonl new file mode 100644 index 0000000000..0753939bdb --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/GrokOpenCodex/usage.jsonl @@ -0,0 +1,2 @@ +{"requestId":"ocx-39907256af2ab94ec49657f775121717","timestamp":1788597898129,"provider":"xai","model":"grok-4.5","admissionKind":"loopback","inboundProtocol":"responses","accountLogLabel":"oecc4c4","resolvedModel":"grok-4.5","requestedModel":"xai/grok-4.5","modelSupportsServiceTier":false,"tierOutcome":{"wireKind":null,"wireValue":null,"fastOutcome":"unknown","confirmation":"unknown"},"status":200,"durationMs":21,"usageStatus":"reported","usage":{"inputTokens":3,"outputTokens":2,"totalTokens":5},"totalTokens":5,"attempts":[{"ordinal":1,"provider":"xai","credentialSource":"grok-oauth","model":"grok-4.5","adapter":"openai-responses","status":200,"durationMs":8,"sendCount":2,"recoveryKinds":["oauth-401"],"usageStatus":"reported","accountLogLabel":"oecc4c4","usage":{"inputTokens":3,"outputTokens":2,"totalTokens":5},"totalTokens":5,"tierOutcome":{"wireKind":null,"wireValue":null,"fastOutcome":"unknown","confirmation":"unknown"}}],"routeDecision":{"version":1,"decisionId":"d4ec9bf70dd5","createdAt":1788597898139,"requestedModel":"xai/grok-4.5","routeKind":"explicit-provider","requirements":[],"candidates":[{"provider":"xai","model":"grok-4.5","eligible":true,"exclusions":[]}],"selected":{"candidateIndex":0,"provider":"xai","model":"grok-4.5","reason":"explicit-provider-namespace"}}} +{"requestId":"ocx-5dba1b394c1c4a4ef822482e9856c31d","timestamp":1788597898158,"provider":"xai","model":"grok-4.5","admissionKind":"loopback","inboundProtocol":"chat","requestedModel":"xai/grok-4.5","status":200,"durationMs":2,"firstOutputMs":2,"usageStatus":"reported","usage":{"inputTokens":3,"outputTokens":2},"totalTokens":5,"attempts":[{"ordinal":1,"provider":"xai","credentialSource":"xai-api-key","model":"grok-4.5","adapter":"openai-chat","status":200,"durationMs":0,"firstOutputMs":0,"sendCount":1,"recoveryKinds":[],"usageStatus":"reported","usage":{"inputTokens":3,"outputTokens":2},"totalTokens":5}],"routeDecision":{"version":1,"decisionId":"40ee37e00887","createdAt":1788597898159,"requestedModel":"xai/grok-4.5","routeKind":"explicit-provider","requirements":[],"candidates":[{"provider":"xai","model":"grok-4.5","eligible":true,"exclusions":[]}],"selected":{"candidateIndex":0,"provider":"xai","model":"grok-4.5","reason":"explicit-provider-namespace"}}} diff --git a/Tests/CodexBarTests/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..86ea7ca4bd --- /dev/null +++ b/Tests/CodexBarTests/GrokCostUsagePricingTests.swift @@ -0,0 +1,408 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@Suite(.serialized) +struct GrokCostUsagePricingTests: GrokLocalSessionScannerTestSupport { + @Test + func `a turn without recorded spend reports exact tokens and the public list price`() throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let turnAt = try self.localDate(day: 20, hour: 12) + 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 == 1) + #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 == 1) + #expect(snapshot.daily.first?.coverageCounts.priced == 0) + #expect(snapshot.daily.first?.coverageCounts.estimated == 1) + } + + @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]) + // A window that kept a priced row keeps its disclosure; one that kept none claims nothing rather + // than inheriting the snapshot's. + #expect(narrowed.costProvenance == full.costProvenance) + #expect(full.narrowed(toHistoryDays: 1, calendar: calendar).costProvenance == .unknown) + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + 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]) + } +} + +extension GrokCostUsagePricingTests { + @Test + func `xai catalog changes use a separate fingerprint from Codex caches`() throws { + let first = try Self.modelsDevArtifact(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-5.6-sol": { + "id": "gpt-5.6-sol", + "cost": { "input": 5, "output": 30 } + } + } + }, + "xai": { + "id": "xai", + "models": { + "grok-4.6": { + "id": "grok-4.6", + "cost": { "input": 2, "output": 6 } + } + } + } + } + """) + let xaiPriceChanged = try Self.modelsDevArtifact(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-5.6-sol": { + "id": "gpt-5.6-sol", + "cost": { "input": 5, "output": 30 } + } + } + }, + "xai": { + "id": "xai", + "models": { + "grok-4.6": { + "id": "grok-4.6", + "cost": { "input": 3, "output": 7 } + } + } + } + } + """) + + let firstCodexKey = CostUsagePricingKey.codex(modelsDevArtifact: first, formulaVersion: 1) + let changedCodexKey = CostUsagePricingKey.codex(modelsDevArtifact: xaiPriceChanged, formulaVersion: 1) + let firstXAIKey = CostUsagePricingKey.codex( + modelsDevArtifact: first, + formulaVersion: 1, + modelsDevProviderIDs: CostUsagePricing.xaiModelsDevProviderIDs) + let changedXAIKey = CostUsagePricingKey.codex( + modelsDevArtifact: xaiPriceChanged, + formulaVersion: 1, + modelsDevProviderIDs: CostUsagePricing.xaiModelsDevProviderIDs) + + #expect(firstCodexKey == changedCodexKey) + #expect(firstXAIKey != changedXAIKey) + } + + private static func modelsDevArtifact(_ json: String) throws -> ModelsDevCacheArtifact { + let catalog = try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8)) + return ModelsDevCacheArtifact( + version: ModelsDevCache.artifactVersion, + fetchedAt: Date(timeIntervalSince1970: 0), + catalog: catalog) + } +} diff --git a/Tests/CodexBarTests/GrokLocalSessionScannerTestSupport.swift b/Tests/CodexBarTests/GrokLocalSessionScannerTestSupport.swift new file mode 100644 index 0000000000..2da671745a --- /dev/null +++ b/Tests/CodexBarTests/GrokLocalSessionScannerTestSupport.swift @@ -0,0 +1,216 @@ +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, costUsdTicks: Int? = nil) -> [String: Any] { + self.usage( + input: input, + output: output, + modelCalls: 1, + costUsdTicks: costUsdTicks, + modelUsage: [ + "grok-4.6-build": self.modelUsage( + input: input, + output: output, + modelCalls: 1, + costUsdTicks: costUsdTicks), + ]) + } + + func usage( + input: Int, + output: Int, + cachedRead: Int = 0, + cacheCreation: Int = 0, + reasoning: Int = 0, + modelCalls: Int?, + costUsdTicks: Int? = nil, + 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, + ] + if let modelCalls { + result["modelCalls"] = modelCalls + } + // Omitted by default so the existing suites keep exercising the public-card fallback; a test that + // wants the recorded-spend path asks for it explicitly. + if let costUsdTicks { + result["costUsdTicks"] = costUsdTicks + } + return result + } + + func modelUsage( + input: Int, + output: Int, + cachedRead: Int = 0, + cacheCreation: Int = 0, + reasoning: Int = 0, + modelCalls: Int?, + costUsdTicks: Int? = nil) -> [String: Any] + { + var result: [String: Any] = [ + "inputTokens": input, + "outputTokens": output, + "totalTokens": input + output, + "cachedReadTokens": cachedRead, + "cacheCreationTokens": cacheCreation, + "reasoningTokens": reasoning, + ] + if let modelCalls { + result["modelCalls"] = modelCalls + } + if let costUsdTicks { + result["costUsdTicks"] = costUsdTicks + } + 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..98e88b1a99 100644 --- a/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift +++ b/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift @@ -1,101 +1,807 @@ 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( - at: session.appendingPathComponent("signals.json"), - tokens: 100, - model: "grok-4.6", - date: yesterday) - let summary = GrokLocalSessionScanner.summarize( + model: "grok-signals-only", + tokens: 999_999, + to: signalsOnly.appendingPathComponent("signals.json"), + modificationDate: now) + try self.writeSignals( + 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 `session discovery stops at its entry budget and marks history incomplete`() throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let sessionsRoot = fixture.session.deletingLastPathComponent() + let turnAt = try self.localDate(day: 20, hour: 17, minute: 45) + try self.writeUpdates( + [self.turn(timestamp: turnAt, usage: self.singleModelUsage(input: 10, output: 1))], + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: turnAt) + let secondSession = sessionsRoot.appendingPathComponent("session-b", isDirectory: true) + try FileManager.default.createDirectory(at: secondSession, withIntermediateDirectories: true) + try self.writeUpdates( + [self.turn(timestamp: turnAt, usage: self.singleModelUsage(input: 20, output: 2))], + to: secondSession.appendingPathComponent("updates.jsonl"), + modificationDate: turnAt.addingTimeInterval(1)) + let limits = GrokLocalSessionScanLimits( + maximumFileBytes: 64 * 1024, + maximumLineBytes: 64 * 1024, + maximumTurnsPerFile: 10, + maximumSessions: 10, + maximumDiscoveryEntries: 3) + + let summary = try GrokLocalSessionScanner.summarize( + env: ["GROK_HOME": fixture.root.path], lookbackDays: 7, - now: Date()) - #expect(summary.toCostUsageTokenSnapshot(historyDays: 7) == nil) + now: turnAt.addingTimeInterval(120), + modelsDevCatalog: Self.catalog(), + scanLimits: limits) + + #expect(summary.sessionCount == 1) + #expect(summary.totalTokens == 11 || summary.totalTokens == 22) + #expect(!summary.historyCoverageIsEstablished) + } + + @Test + func `parse cache caps retained session entries globally`() throws { + GrokLocalSessionScanner.resetParseCacheForTesting() + defer { GrokLocalSessionScanner.resetParseCacheForTesting() } + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let sessionsRoot = fixture.session.deletingLastPathComponent() + let turnAt = try self.localDate(day: 20, hour: 17, minute: 50) + for index in 0..<80 { + let session = sessionsRoot.appendingPathComponent("session-\(index)", isDirectory: true) + try FileManager.default.createDirectory(at: session, withIntermediateDirectories: true) + try self.writeUpdates( + [self.turn(timestamp: turnAt, usage: self.singleModelUsage(input: 1, output: 1))], + to: session.appendingPathComponent("updates.jsonl"), + modificationDate: turnAt.addingTimeInterval(60)) + } + + let summary = try self.summarize(fixture: fixture, now: turnAt.addingTimeInterval(120)) + + #expect(summary.totalTokens == 160) + #expect(summary.historyCoverageIsEstablished) + #expect(GrokLocalSessionScanner.parseCacheEntryCountForTesting() <= 64) + #expect(GrokLocalSessionScanner.parseCacheTurnCountForTesting() <= 64) + } + + @Test + func `global scan budgets retain newest sessions and mark history incomplete`() throws { + GrokLocalSessionScanner.resetParseCacheForTesting() + defer { GrokLocalSessionScanner.resetParseCacheForTesting() } + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let sessionsRoot = fixture.session.deletingLastPathComponent() + let firstAt = try self.localDate(day: 20, hour: 18) + for index in 0..<5 { + let session = sessionsRoot.appendingPathComponent("budget-session-\(index)", isDirectory: true) + try FileManager.default.createDirectory(at: session, withIntermediateDirectories: true) + let turnAt = firstAt.addingTimeInterval(TimeInterval(index)) + try self.writeUpdates( + [self.turn(timestamp: turnAt, usage: self.singleModelUsage(input: index + 1, output: 0))], + to: session.appendingPathComponent("updates.jsonl"), + modificationDate: turnAt) + } + let limits = GrokLocalSessionScanLimits( + maximumFileBytes: 64 * 1024, + maximumLineBytes: 64 * 1024, + maximumTurnsPerFile: 10, + maximumSessions: 3, + maximumTotalBytes: 1024 * 1024, + maximumTotalTurns: 2) + + let summary = try GrokLocalSessionScanner.summarize( + env: ["GROK_HOME": fixture.root.path], + lookbackDays: 7, + now: firstAt.addingTimeInterval(60), + modelsDevCatalog: Self.catalog(), + scanLimits: limits) + + #expect(summary.totalTokens == 9) + #expect(summary.sessionCount == 2) + #expect(summary.lastSessionAt == firstAt.addingTimeInterval(4)) + #expect(!summary.historyCoverageIsEstablished) + #expect(GrokLocalSessionScanner.parseCacheEntryCountForTesting() == 2) + #expect(GrokLocalSessionScanner.parseCacheTurnCountForTesting() == 2) + } + + @Test + func `absurd model call count promptly falls back without iterating file content`() throws { + let fixture = try self.makeFixture() + 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) + // The fixture records no spend, so the request is covered by the public-card fallback. + #expect(snapshot.daily.first?.coverageCounts.estimated == 1) + #expect(snapshot.daily.first?.coverageCounts.priced == 0) + } + + @MainActor + @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(pathPrefix: fixture.root.path) + 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(pathPrefix: fixture.root.path) == 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) } + // The unstubbed publish path scans against the real clock, so anchor the fixture to now + // instead of a fixed calendar day that eventually falls outside the seven-day window. + let turnAt = Date().addingTimeInterval(-3600) + let updates = fixture.session.appendingPathComponent("updates.jsonl") + let firstTurn = self.turn(timestamp: turnAt, usage: self.singleModelUsage(input: 70, output: 7)) + try self.writeUpdates( + [firstTurn], + 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 + let catalog = try Self.catalog() + store._test_grokLocalTokenScannerOverride = { historyDays in + fallbackScanCount += 1 + return GrokLocalSessionScanner.summarize( + env: ["GROK_HOME": fixture.root.path], + lookbackDays: historyDays, + now: turnAt.addingTimeInterval(600), + modelsDevCatalog: catalog) + .toCostUsageTokenSnapshot(historyDays: historyDays) + } + store._test_providerFetchOutcomeOverride = { provider in + #expect(provider == .grok) + return ProviderFetchOutcome(result: .failure(URLError(.badServerResponse)), attempts: []) + } + await store.refreshProvider(.grok) + for _ in 0..<100 { + if fallbackScanCount >= 1 { break } + await Task.yield() + } + + #expect(fallbackScanCount == 1) + #expect(store.tokenSnapshotPublicationForCurrentProviderConfig(for: .grok)?.snapshot?.last30DaysTokens == 77) + + let secondTurnAt = turnAt.addingTimeInterval(180) + let secondTurn = self.turn( + timestamp: secondTurnAt, + usage: self.singleModelUsage(input: 20, output: 3)) + try self.writeUpdates( + [firstTurn, secondTurn], + to: updates, + modificationDate: secondTurnAt.addingTimeInterval(60)) + await store.refreshProvider(.grok) + for _ in 0..<100 { + if fallbackScanCount >= 2, + store.tokenSnapshotPublicationForCurrentProviderConfig(for: .grok)?.snapshot?.last30DaysTokens == 100 + { + break + } + await Task.yield() + } + + #expect(fallbackScanCount == 2) + #expect(store.tokenSnapshotPublicationForCurrentProviderConfig(for: .grok)?.snapshot?.last30DaysTokens == 100) + for _ in 0..<100 { + if store.grokLocalTokenScanTask == nil { break } + await Task.yield() + } + #expect(store.grokLocalTokenScanTask == nil) + + store._test_grokLocalTokenScannerOverride = nil + try FileManager.default.removeItem(at: updates) + let empty = await store.scanAndPublishGrokLocalTokenSnapshot(historyDays: 7) + + #expect(empty == nil) + #expect(store.tokenSnapshotPublicationForCurrentProviderConfig(for: .grok)?.snapshot == nil) + + let newTurnAt = turnAt.addingTimeInterval(180) + try self.writeUpdates( + [self.turn(timestamp: newTurnAt, usage: self.singleModelUsage(input: 70, output: 7))], + to: updates, + modificationDate: newTurnAt.addingTimeInterval(60)) + fallbackScanCount = 0 + store._test_grokLocalTokenScannerOverride = { historyDays in + fallbackScanCount += 1 + return GrokLocalSessionScanner.summarize( + env: ["GROK_HOME": fixture.root.path], + lookbackDays: historyDays, + now: newTurnAt.addingTimeInterval(120), + modelsDevCatalog: catalog) + .toCostUsageTokenSnapshot(historyDays: historyDays) + } + + await store.refreshProvider(.grok) + for _ in 0..<100 { + if store.tokenSnapshotPublicationForCurrentProviderConfig(for: .grok)?.snapshot?.last30DaysTokens == 77 { + break + } + await Task.yield() + } + + #expect(fallbackScanCount == 1) + #expect(store.tokenSnapshotPublicationForCurrentProviderConfig(for: .grok)?.snapshot?.last30DaysTokens == 77) + + await store.refreshProvider(.grok) + for _ in 0..<100 { + if fallbackScanCount >= 2 { break } + await Task.yield() + } + + #expect(fallbackScanCount == 2) + } + + @MainActor + @Test + func `preservable remote failure rescans and selects newer local tokens`() async throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let firstTurnAt = try self.localDate(day: 20, hour: 19, minute: 30) + let updates = fixture.session.appendingPathComponent("updates.jsonl") + let firstTurn = self.turn( + timestamp: firstTurnAt, + usage: self.singleModelUsage(input: 70, output: 7)) + try self.writeUpdates( + [firstTurn], + to: updates, + modificationDate: firstTurnAt.addingTimeInterval(60)) + + let catalog = try Self.catalog() + let priorScanAt = firstTurnAt.addingTimeInterval(120) + let priorLocal = try #require(GrokLocalSessionScanner.summarize( + env: ["GROK_HOME": fixture.root.path], + lookbackDays: GrokLocalSessionScanner.maximumLookbackDays, + now: priorScanAt, + modelsDevCatalog: catalog) + .toCostUsageTokenSnapshot(historyDays: GrokLocalSessionScanner.maximumLookbackDays)) + let settings = testSettingsStore(suiteName: "GrokLocalSessionScannerTests-preservable-failure") + let metadata = ProviderDescriptorRegistry.descriptor(for: .grok).metadata + settings.setProviderEnabled(provider: .grok, metadata: metadata, enabled: true) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: ["GROK_HOME": fixture.root.path]) + let retainedRemote = UsageSnapshot( + primary: nil, + secondary: nil, + costUsage: priorLocal, + updatedAt: priorScanAt) + store.snapshots[UsageProvider.grok.instanceID] = retainedRemote + + let secondTurnAt = firstTurnAt.addingTimeInterval(180) + let secondTurn = self.turn( + timestamp: secondTurnAt, + usage: self.singleModelUsage(input: 20, output: 3)) + try self.writeUpdates( + [firstTurn, secondTurn], + to: updates, + modificationDate: secondTurnAt.addingTimeInterval(60)) + let refreshedScanAt = secondTurnAt.addingTimeInterval(120) + var scanCount = 0 + store._test_grokLocalTokenScannerOverride = { historyDays in + scanCount += 1 + return GrokLocalSessionScanner.summarize( + env: ["GROK_HOME": fixture.root.path], + lookbackDays: historyDays, + now: refreshedScanAt, + modelsDevCatalog: catalog) + .toCostUsageTokenSnapshot(historyDays: historyDays) + } + store._test_providerFetchOutcomeOverride = { provider in + #expect(provider == .grok) + return ProviderFetchOutcome(result: .failure(URLError(.timedOut)), attempts: []) + } + + await store.refreshProvider(.grok) + for _ in 0..<100 { + if store.tokenSnapshotPublicationForCurrentProviderConfig(for: .grok)? + .snapshot?.last30DaysTokens == 100 + { + break + } + await Task.yield() + } + + #expect(scanCount == 1) + #expect(store.snapshots[UsageProvider.grok.instanceID]?.costUsage?.last30DaysTokens == 77) + let selected = store.tokenSnapshotForLiveProviderConsumer( + fromProviderSnapshot: store.snapshots[UsageProvider.grok.instanceID], + provider: .grok) + #expect(selected?.last30DaysTokens == 100) + #expect(selected?.updatedAt == refreshedScanAt) + + await store.refreshProvider(.grok) + for _ in 0..<100 { + if scanCount >= 2 { break } + await Task.yield() + } + #expect(scanCount == 2) } @Test func `local scan clock wins over a stale remote snapshot`() throws { let calendar = Calendar.current - 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 +812,271 @@ 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 +} + +private final class GrokScanExecutorGate: @unchecked Sendable { + private let lock = NSLock() + private let releaseSignal = DispatchSemaphore(value: 0) + private var entered = false + private var waiter: CheckedContinuation? + + func enter() { + self.lock.lock() + let first = !self.entered + self.entered = true + let waiter = self.waiter + self.waiter = nil + self.lock.unlock() + waiter?.resume() + if first { _ = self.releaseSignal.wait(timeout: .now() + 5) } + } + + func waitUntilInside() async { + await withCheckedContinuation { continuation in + self.lock.lock() + let entered = self.entered + if !entered { self.waiter = continuation } + self.lock.unlock() + if entered { continuation.resume() } + } + } + + func release() { + self.releaseSignal.signal() + } +} + +private actor GrokScanCompletionFlag { + private(set) var isFinished = false + func markFinished() { + self.isFinished = true + } +} + +extension GrokLocalSessionScannerTests { + /// The refreshable entry point used to call the synchronous corpus scanner inline, so its callers + /// ran a potentially multi-minute scan on the cooperative pool. It must queue through the shared + /// serial executor instead; occupying that executor therefore has to hold this scan back. + @Test + func `refreshable scan queues through the shared corpus scan executor`() async throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let cacheRoot = fixture.root.appendingPathComponent("models-dev", isDirectory: true) + try FileManager.default.createDirectory(at: cacheRoot, withIntermediateDirectories: true) + let now = try self.localDate(day: 10, hour: 12) + let turnAt = try self.localDate(day: 10, hour: 9) + try self.writeUpdates( + [self.turn(timestamp: turnAt, usage: self.singleModelUsage(input: 100, output: 10))], + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: turnAt.addingTimeInterval(60)) + + let gate = GrokScanExecutorGate() + let blocker = Task { try await CostUsageScanExecutor.run { _ in gate.enter() } } + await gate.waitUntilInside() + + let finished = GrokScanCompletionFlag() + let scan = Task { () -> GrokLocalSessionSummary in + let summary = await GrokLocalSessionScanner.summarizeRequestingPricingRefresh( + env: ["GROK_HOME": fixture.root.path], + lookbackDays: 7, + now: now, + modelsDevCacheRoot: cacheRoot) {} + await finished.markFinished() + return summary + } + // An inline scan of this fixture finishes in microseconds, so poll well past that: staying + // unfinished for the whole window is only possible if the scan is queued behind the blocker. + for _ in 0..<60 { + if await finished.isFinished { break } + try await Task.sleep(nanoseconds: 5_000_000) + } + #expect(await finished.isFinished == false) + + gate.release() + try await blocker.value + let summary = await scan.value + #expect(summary.totalTokens == 110) + } + + /// Consumers render this history as an inclusive local-day window. Deriving the scan cutoff from the + /// current instant instead collected a partial extra day that those consumers then discarded. + @Test + func `history window starts at the first local day rather than the current instant`() throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let now = try self.localDate(day: 10, hour: 12) + let firstDisplayedDay = try self.localDate(day: 8, hour: 3) + let dayBeforeWindow = try self.localDate(day: 7, hour: 20) + try self.writeUpdates( + [ + self.turn(timestamp: dayBeforeWindow, usage: self.singleModelUsage(input: 500, output: 50)), + self.turn(timestamp: firstDisplayedDay, usage: self.singleModelUsage(input: 100, output: 10)), + ], + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: now.addingTimeInterval(-60)) + + let summary = GrokLocalSessionScanner.summarize( + env: ["GROK_HOME": fixture.root.path], + lookbackDays: 3, + now: now) + + // A three-day window ending today covers days 8-10; the day-7 turn is outside it. + #expect(summary.totalTokens == 110) + #expect(summary.daily.count == 1) + } + + /// The CLI's own figure already carries the price tier and any promotional rate, so it must win over a + /// public-card reconstruction. The fixture's tokens would reconstruct to a very different number. + @Test + func `recorded spend prices the turn instead of the public card`() throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let turnAt = try self.localDate(day: 20, hour: 12) + try self.writeUpdates( + [self.turn( + timestamp: turnAt, + usage: self.singleModelUsage(input: 1_000_000, output: 100_000, costUsdTicks: 3_708_179_400))], + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: turnAt.addingTimeInterval(60)) + + let summary = try self.summarize(fixture: fixture, now: turnAt.addingTimeInterval(120)) + let day = try #require(summary.daily.first) + let snapshot = try #require(summary.toCostUsageTokenSnapshot(historyDays: 7)) + + #expect(abs((day.costUSD ?? 0) - 0.37081794) < 0.000000000001) + #expect(day.estimatedRequestCount == 0) + #expect(day.unpricedRequestCount == 0) + #expect(summary.costProvenance == .vendorMetered) + #expect(snapshot.costProvenance == .vendorMetered) + #expect(snapshot.daily.first?.coverageCounts.priced == 1) + #expect(snapshot.daily.first?.coverageCounts.estimated == 0) + // The same tokens on the public tier-1 card come to $2.60, so the recorded figure is not a + // reconstruction that happens to agree. + let breakdown = try #require(day.modelBreakdowns.first) + #expect(abs((breakdown.costUSD ?? 0) - 0.37081794) < 0.000000000001) + } + + /// A record that reports 0 has no recorded spend to read, which is how a small share of turns arrive. + @Test + func `a zero tick record falls back to the public card`() throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let turnAt = try self.localDate(day: 20, hour: 12) + try self.writeUpdates( + [self.turn( + timestamp: turnAt, + usage: self.singleModelUsage(input: 1000, output: 100, costUsdTicks: 0))], + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: turnAt.addingTimeInterval(60)) + + let summary = try self.summarize(fixture: fixture, now: turnAt.addingTimeInterval(120)) + let day = try #require(summary.daily.first) + + #expect(day.estimatedRequestCount == 1) + #expect((day.costUSD ?? 0) > 0) + #expect(summary.costProvenance == .listPriceEstimate) + } + + /// A corpus that uses both sources must say so rather than claiming either one for the whole window. + @Test + func `a corpus mixing recorded and reconstructed turns publishes a mixed window`() throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let recordedAt = try self.localDate(day: 20, hour: 12) + let reconstructedAt = try self.localDate(day: 21, hour: 12) + try self.writeUpdates( + [ + self.turn( + timestamp: recordedAt, + usage: self.singleModelUsage(input: 1000, output: 100, costUsdTicks: 1_000_000_000)), + self.turn( + timestamp: reconstructedAt, + usage: self.singleModelUsage(input: 1000, output: 100)), + ], + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: reconstructedAt.addingTimeInterval(60)) + + let summary = try self.summarize(fixture: fixture, now: reconstructedAt.addingTimeInterval(120)) + let snapshot = try #require(summary.toCostUsageTokenSnapshot(historyDays: 7)) + let recordedDay = try #require(summary.daily.first { $0.estimatedRequestCount == 0 }) + let reconstructedDay = try #require(summary.daily.first { $0.estimatedRequestCount == 1 }) + + #expect(summary.costProvenance == .mixed) + #expect(snapshot.costProvenance == .mixed) + #expect(abs((recordedDay.costUSD ?? 0) - 0.1) < 0.000000000001) + #expect((reconstructedDay.costUSD ?? 0) > 0) + #expect(abs((reconstructedDay.costUSD ?? 0) - 0.1) > 0.000000000001) } } diff --git a/Tests/CodexBarTests/GrokNativeOverflowTests.swift b/Tests/CodexBarTests/GrokNativeOverflowTests.swift new file mode 100644 index 0000000000..eca9511c3d --- /dev/null +++ b/Tests/CodexBarTests/GrokNativeOverflowTests.swift @@ -0,0 +1,103 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct GrokNativeOverflowTests: GrokLocalSessionScannerTestSupport { + @Test(arguments: [false, true]) + func `overflowing derived totals preserve explicit totals and token classes`(_ hasExplicitTotal: Bool) throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let now = try self.localDate(day: 20, hour: 15) + var usage: [String: Any] = ["inputTokens": Int.max, "outputTokens": 1, "costUsdTicks": 100] + if hasExplicitTotal { usage["totalTokens"] = 7 } + usage["modelUsage"] = ["grok-4.6-build": usage] + try self.writeUpdates( + [self.turn(timestamp: now, usage: usage)], + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: now) + let summary = try self.summarize(fixture: fixture, now: now) + let expected: Int? = hasExplicitTotal ? 7 : nil + #expect(summary.totalTokens == expected) + #expect(summary.historyCoverageIsEstablished == hasExplicitTotal) + let day = try #require(summary.daily.first) + #expect(day.inputTokens == Int.max) + #expect(day.outputTokens == 1) + #expect(day.totalTokens == expected) + #expect(day.modelBreakdowns.first?.totalTokens == expected) + let snapshot = try #require(summary.toCostUsageTokenSnapshot(historyDays: 7)) + #expect(snapshot.last30DaysTokens == expected) + #expect(snapshot.sessionTokens == expected) + #expect(snapshot.last30DaysCostUSD == 100 / GrokLocalSessionScanner.costUsdTicksPerUSD) + #expect(snapshot.costProvenance == .vendorMetered) + } + + @Test(arguments: [false, true]) + func `native bucket model and window sums keep overflow unknown after later turns`(_ separateDays: Bool) throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let now = try self.localDate(day: 20, hour: 15) + let rows = [Int.max, 1, 5].enumerated().map { index, count in + var usage: [String: Any] = [ + "inputTokens": count, "outputTokens": 2, "totalTokens": count, + "cachedReadTokens": count, "cacheCreationTokens": count, "reasoningTokens": count, + "costUsdTicks": 100, + ] + usage["modelUsage"] = ["grok-4.6-build": usage] + let offset = separateDays ? Double(index - 2) * 86400 : 0 + return self.turn(timestamp: now.addingTimeInterval(offset), usage: usage) + } + try self.writeUpdates( + rows, + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: now) + for _ in 0..<2 { + // The second scan consumes the parsed cache and must retain the same unknown totals. + let summary = try self.summarize(fixture: fixture, now: now) + #expect(summary.totalTokens == nil) + #expect(!summary.historyCoverageIsEstablished) + let snapshot = try #require(summary.toCostUsageTokenSnapshot(historyDays: 7)) + #expect(snapshot.last30DaysTokens == nil) + #expect(snapshot.daily.count == (separateDays ? 3 : 1)) + #expect(snapshot.last30DaysRequests == 3) + if !separateDays { + let day = try #require(snapshot.daily.first) + #expect(day.totalTokens == nil) + #expect(day.inputTokens == nil) + #expect(day.cacheReadTokens == nil) + #expect(day.cacheCreationTokens == nil) + #expect(day.reasoningTokens == nil) + #expect(day.outputTokens == 6) + #expect(day.modelBreakdowns?.first?.totalTokens == nil) + #expect(day.modelBreakdowns?.first?.inputTokens == nil) + #expect(day.modelBreakdowns?.first?.outputTokens == 6) + } + } + } + + @Test(arguments: ["true", "1.5", "1e40", "9223372036854775808", "-1"]) + func `invalid native token numbers remain unknown without erasing valid fields`(_ literal: String) throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let now = try self.localDate(day: 20, hour: 15) + let raw = """ + {"timestamp":\(Int(now.timeIntervalSince1970)),"params":{"update":{"sessionUpdate":"turn_completed",\ + "usage":{"inputTokens":\(literal),"outputTokens":2,"costUsdTicks":100,\ + "modelUsage":{"grok-4.6-build":{"inputTokens":\(literal),"outputTokens":2,"costUsdTicks":100}}}}}} + """ + try self.writeUpdates( + [], + rawLines: [raw], + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: now) + let summary = try self.summarize(fixture: fixture, now: now) + let day = try #require(summary.daily.first) + #expect(summary.totalTokens == nil) + #expect(!summary.historyCoverageIsEstablished) + #expect(day.inputTokens == nil) + #expect(day.outputTokens == 2) + #expect(day.modelBreakdowns.first?.inputTokens == nil) + #expect(day.modelBreakdowns.first?.outputTokens == 2) + #expect(day.costUSD == 100 / GrokLocalSessionScanner.costUsdTicksPerUSD) + } +} diff --git a/Tests/CodexBarTests/GrokOpenCodexUsageTests.swift b/Tests/CodexBarTests/GrokOpenCodexUsageTests.swift new file mode 100644 index 0000000000..4d30880339 --- /dev/null +++ b/Tests/CodexBarTests/GrokOpenCodexUsageTests.swift @@ -0,0 +1,325 @@ +import CryptoKit +import Foundation +import SQLite3 +import Testing +@testable import CodexBar +@testable import CodexBarCore + +struct GrokOpenCodexUsageTests { + private static let now = Date(timeIntervalSince1970: 1_787_270_400) + private static let calendar = CostUsageBucketTimeZone.calendar(identifier: "UTC") + private static let pricing = CostUsageCustomPricing( + entries: ["xai/grok-test": .init(input: 2, output: 10)], fingerprint: "grok-test") + + @Test func `mixed attempts attribute only reported OAuth usage without the parent total`() throws { + let entry = try Self.entry(attempts: [ + Self.attempt(ordinal: 1), + Self.attempt(ordinal: 2, source: "xai-api-key"), + Self.attempt(ordinal: 3, provider: "openai"), + ]) + let snapshot = try #require(Self.snapshots([entry, entry])[.grok]) + #expect(snapshot.last30DaysTokens == 5) + #expect(snapshot.daily.first?.totalTokens == 5) + #expect(snapshot.sessions.first?.totalTokens == 5) + #expect(snapshot.costProvenance == .listPriceEstimate) + #expect(snapshot.daily.first?.coverageCounts.priced == 0) + #expect(snapshot.daily.first?.estimatedRequestCount == 1) + let cost = try #require(snapshot.last30DaysCostUSD) + #expect(abs(cost - 0.000026) < 0.000000000001) + } + + @Test func `legacy unknown API key and malformed attempts do not enter the subscription`() throws { + let rejected: [[String: Any]] = [ + Self.attempt(source: nil), Self.attempt(source: "oauth"), Self.attempt(source: "xai-api-key"), + Self.attempt(provider: "custom"), Self.attempt(model: "openai/grok-test"), + Self.attempt(changes: ["sendCount": 0]), Self.attempt(changes: ["locallyAnswered": true]), + Self.attempt(changes: ["usageStatus": "estimated"]), + Self.attempt(changes: ["usageStatus": "unreported"]), + Self.attempt(changes: ["ordinal": true]), Self.attempt(changes: ["ordinal": 1.5]), + Self.attempt(changes: ["sendCount": 1.5]), + Self.attempt(changes: ["sendCount": true]), + Self.attempt(changes: ["ordinal": 1e40]), Self.attempt(changes: ["sendCount": 1e40]), + ] + for attempt in rejected { + #expect(try Self.snapshots([Self.entry(attempts: [attempt])])[.grok] == nil) + } + let forgedTop = try Self.entry(attempts: [], changes: ["credentialSource": "grok-oauth"]) + #expect(Self.snapshots([forgedTop])[.grok] == nil) + let duplicates = try Self.entry(attempts: [Self.attempt(), Self.attempt()]) + #expect(Self.snapshots([duplicates])[.grok] == nil) + } + + @Test func `OAuth attempt aggregation preserves overflow and estimated coverage`() throws { + let attempts = [Int.max, 1, 5].enumerated().map { index, value in + Self.attempt(ordinal: index + 1, changes: [ + "usage": ["inputTokens": value, "outputTokens": 2, "totalTokens": value], + "totalTokens": value, + ]) + } + let entry = try Self.entry(attempts: attempts) + #expect(entry.attempts.first?.usage?.inputTokens == Int.max) + let snapshot = try #require(Self.snapshots([entry])[.grok]) + #expect(snapshot.last30DaysTokens == nil) + #expect(snapshot.sessionTokens == nil) + let day = try #require(snapshot.daily.first) + #expect(day.totalTokens == nil) + #expect(day.inputTokens == nil) + #expect(day.outputTokens == 6) + #expect(day.coverageCounts.priced == 0) + #expect(day.estimatedRequestCount == 3) + #expect(day.modelBreakdowns?.first?.totalTokens == nil) + #expect(snapshot.hourly.allSatisfy { $0.totalTokens == nil }) + #expect(snapshot.costProvenance == .listPriceEstimate) + } + + @Test func `latest request replaces earlier attribution before subscription grouping`() throws { + let earlier = try Self.entry(attempts: [Self.attempt()]) + let latest = try Self.entry(attempts: [Self.attempt(source: "xai-api-key")]) + #expect(Self.snapshots([earlier, latest])[.grok] == nil) + } + + @Test func `missing token classes and unknown model prices retain tokens without dollars`() throws { + for attempt in [ + Self.attempt(changes: ["usage": ["totalTokens": 5]]), + Self.attempt(model: "grok-fictional-unpriced"), + ] { + let snapshot = try #require(try Self.snapshots([Self.entry(attempts: [attempt])])[.grok]) + #expect(snapshot.last30DaysTokens == 5) + #expect(snapshot.last30DaysCostUSD == nil) + #expect(snapshot.daily.first?.unpricedRequestCount == 1) + #expect(snapshot.daily.first?.estimatedRequestCount == 0) + } + } + + @Test func `attempt provenance survives cache reopen incremental append and v2 rebuild`() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let log = root.appendingPathComponent("usage.jsonl") + let cache = root.appendingPathComponent("cache") + let firstLine = try Self.line(attempts: [Self.attempt()]) + "\n" + try Data(firstLine.utf8).write(to: log) + let store = OpenCodexUsageStore(cacheRoot: cache) + let first = try store.loadEntries(logURL: log) + let reopened = OpenCodexUsageStore(cacheRoot: cache) + let recorder = OpenCodexUsageParser.LogReadRecorder() + let cached = try OpenCodexUsageStore.withLogReadRecorderForTesting(recorder) { + try reopened.loadEntries(logURL: log) + } + #expect(cached == first) + #expect(recorder.snapshot().bytesRead == 0) + #expect(cached.first?.attempts.first?.credentialSource == .grokOAuth) + + let handle = try FileHandle(forWritingTo: log) + try handle.seekToEnd() + try handle.write(contentsOf: Data((Self.line( + attempts: [Self.attempt(source: "xai-api-key")], changes: ["requestId": "api-key"]) + "\n").utf8)) + try handle.close() + let appended = try reopened.loadEntries(logURL: log) + #expect(appended.count == 2) + #expect(Self.snapshots(appended)[.grok]?.last30DaysTokens == 5) + + // A pre-provenance cache must re-read the raw log even when file size and mtime are unchanged. + var database: OpaquePointer? + let path = cache.appendingPathComponent(OpenCodexUsageStore.databaseFilename).path + #expect(sqlite3_open(path, &database) == SQLITE_OK) + #expect(sqlite3_exec(database, "PRAGMA user_version = 2", nil, nil, nil) == SQLITE_OK) + sqlite3_close(database) + let rebuilt = try OpenCodexUsageStore(cacheRoot: cache).loadEntries(logURL: log) + #expect(rebuilt == appended) + #expect(Self.snapshots(rebuilt)[.grok]?.last30DaysTokens == 5) + } + + @Test func `Grok merge preserves recorded and estimated coverage across date filters`() throws { + let supplement = try #require(try Self.snapshots([Self.entry(attempts: [Self.attempt()])])[.grok]) + let yesterday = Self.now.addingTimeInterval(-86400) + let nativeDay = CostUsageDailyReport.Entry( + date: CostUsageLocalDay.key(from: yesterday, calendar: Self.calendar), + inputTokens: 8, + outputTokens: 2, + totalTokens: 10, + costUSD: 0.1, + modelsUsed: nil, + modelBreakdowns: nil, + pricedRequestCount: 1) + let native = CostUsageFetcher.tokenSnapshot( + from: CostUsageDailyReport(data: [nativeDay], summary: nil), + now: Self.now, + historyDays: 7, + calendar: Self.calendar, + costProvenance: .vendorMetered) + let merged = OpenCodexUsageFanOut.mergeSnapshots( + native, supplement, now: Self.now, historyDays: 7, calendar: Self.calendar, provider: .grok) + #expect(merged.last30DaysTokens == 15) + #expect(merged.costProvenance == .mixed) + #expect(GrokLocalSessionSummary.costProvenance(for: [nativeDay], fallback: merged.costProvenance) + == .vendorMetered) + #expect(GrokLocalSessionSummary.costProvenance(for: supplement.daily, fallback: merged.costProvenance) + == .listPriceEstimate) + } + + @Test func `dashboard publishes OAuth attempts only when the existing OpenCodex switch is on`() throws { + let entry = try Self.entry(attempts: [Self.attempt()]) + for enabled in [false, true] { + let config = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.grok.rawValue], + codexAccountIdentities: [], + bucketTimeZoneIdentifier: "UTC", + openCodexUsageLogsEnabled: enabled) + let request = SpendDashboardLoadRequest( + configuration: config, + capturedInputs: [], + unavailableSourceIDs: [], + confirmedEmptySourceIDs: [], + codexRequests: [], + now: Self.now, + force: false) + let result = SpendDashboardSource.mergingOpenCodexInputsWithObservation( + [], + request: request, + environment: ["OPENCODEX_HOME": "/synthetic/opencodex"], + entryLoader: { _ in [entry] }) + #expect(result.inputs.count == (enabled ? 1 : 0)) + if enabled { + #expect(result.inputs.first?.provider == .grok) + #expect(result.inputs.first?.sourceKind == .openCodex) + #expect(result.inputs.first?.snapshot.last30DaysTokens == 5) + } + } + } + + @Test func `non Grok token only routes keep standalone zero and custom prices`() throws { + for provider in ["opencode", "opencode-free"] { + let entry = OpenCodexUsageEntry( + requestID: "standalone-\(provider)", + timestamp: Self.now, + provider: provider, + model: "deepseek-v4-flash-free", + usageStatus: .reported, + usage: OpenCodexTokenUsage(inputTokens: 3, outputTokens: 2, totalTokens: 5), + totalTokens: 5) + let freeCatalogJSON = """ + {"opencode":{"id":"opencode","models":{"deepseek-v4-flash-free":{ + "id":"deepseek-v4-flash-free","cost":{"input":0,"output":0}}}}} + """ + let catalog = try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(freeCatalogJSON.utf8)) + let free = OpenCodexUsageAggregator.snapshot( + entries: [entry], + now: Self.now, + historyDays: 7, + calendar: Self.calendar, + modelsDevCatalog: catalog, + customPricingOverlay: .empty) + #expect(free.last30DaysCostUSD == 0) + for inputRate in [0.0, 2.0] { + let pricing = CostUsageCustomPricing( + entries: ["\(provider)/deepseek-v4-flash-free": .init(input: inputRate, output: inputRate)], + fingerprint: "standalone-\(inputRate)") + let snapshot = OpenCodexUsageAggregator.snapshot( + entries: [entry], + now: Self.now, + historyDays: 7, + calendar: Self.calendar, + customPricing: pricing, + modelsDevCatalog: ModelsDevCatalog(providers: [:])) + let cost = try #require(snapshot.last30DaysCostUSD) + #expect(abs(cost - inputRate * 5 / 1_000_000) < 0.000000000001) + #expect(Self.snapshots([entry]).isEmpty) + } + } + } + + @Test func `captured producer ledger imports through the dashboard disk loader and cache`() throws { + let fixture = try #require(Bundle.module.url( + forResource: "usage", withExtension: "jsonl", subdirectory: "Fixtures/GrokOpenCodex")) + let captured = try Data(contentsOf: fixture) + let digest = SHA256.hash(data: captured).map { String(format: "%02x", $0) }.joined() + #expect(digest == "ef6d8758b40910f6e5993d5b5a105a2ad2834c6c1bd0565ab87b61cf091c4978") + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let log = root.appendingPathComponent("usage.jsonl") + try captured.write(to: log) + let proofNow = Date(timeIntervalSince1970: 1_788_597_900) + let config = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.grok.rawValue], + codexAccountIdentities: [], + bucketTimeZoneIdentifier: "UTC", + openCodexUsageLogsEnabled: true) + let request = SpendDashboardLoadRequest( + configuration: config, + capturedInputs: [], + unavailableSourceIDs: [], + confirmedEmptySourceIDs: [], + codexRequests: [], + now: proofNow, + force: false) + let cache = root.appendingPathComponent("cache") + let imported = SpendDashboardSource.mergingOpenCodexInputsWithObservation( + [], request: request, environment: ["OPENCODEX_HOME": root.path], cacheRoot: cache) + let snapshot = try #require(imported.inputs.first?.snapshot) + #expect(imported.inputs.count == 1) + #expect(imported.inputs.first?.provider == .grok) + #expect(snapshot.last30DaysTokens == 5) + #expect(snapshot.daily.first?.inputTokens == 3) + #expect(snapshot.daily.first?.outputTokens == 2) + let dashboard = SpendDashboardModel.build(inputs: imported.inputs, requestedDays: 7, now: proofNow) + #expect(dashboard.groups.first?.providers.first?.totalTokens == 5) + let recorder = OpenCodexUsageParser.LogReadRecorder() + let reopened = OpenCodexUsageStore.withLogReadRecorderForTesting(recorder) { + SpendDashboardSource.mergingOpenCodexInputsWithObservation( + [], request: request, environment: ["OPENCODEX_HOME": root.path], cacheRoot: cache) + } + #expect(reopened.inputs.first?.snapshot.last30DaysTokens == 5) + #expect(recorder.snapshot().bytesRead == 0) + // Keep the exact producer-written API-key line, without fabricating a replacement record. + let text = try #require(String(data: captured, encoding: .utf8)) + let keyLine = try #require(text.split(separator: "\n").first { $0.contains("\"xai-api-key\"") }) + try Data((keyLine + "\n").utf8).write(to: log) + let keyOnly = SpendDashboardSource.mergingOpenCodexInputsWithObservation( + [], request: request, environment: ["OPENCODEX_HOME": root.path], cacheRoot: cache) + #expect(keyOnly.inputs.isEmpty) + print("producer_capture_sha256=\(digest)") + print("producer_log_rows=2 total_reported_tokens=10 grok_oauth_tokens=5") + print("producer_import_dashboard_tokens=5 cache_reopen_bytes=\(recorder.snapshot().bytesRead)") + print("producer_api_key_only_subscription_rows=\(keyOnly.inputs.count)") + } + + private static func snapshots(_ entries: [OpenCodexUsageEntry]) -> [UsageProvider: CostUsageTokenSnapshot] { + OpenCodexUsageFanOut.snapshotsBySubscription( + entries: entries, now: self.now, historyDays: 7, calendar: self.calendar, customPricing: self.pricing) + } + + private static func attempt( + ordinal: Int = 1, + provider: String = "xai", + model: String = "grok-test", + source: String? = "grok-oauth", + changes: [String: Any] = [:]) -> [String: Any] + { + var row: [String: Any] = [ + "ordinal": ordinal, "provider": provider, "model": model, "adapter": "openai-responses", + "status": 200, "durationMs": 1, "sendCount": 1, "recoveryKinds": [], "usageStatus": "reported", + "usage": ["inputTokens": 3, "outputTokens": 2, "totalTokens": 5], "totalTokens": 5, + ] + row["credentialSource"] = source + row.merge(changes, uniquingKeysWith: { _, latest in latest }) + return row + } + + private static func line(attempts: [[String: Any]], changes: [String: Any] = [:]) throws -> String { + var row: [String: Any] = [ + "requestId": "ocx-mixed", "timestamp": self.now.timeIntervalSince1970 * 1000, + "provider": "combo", "model": "combo/test", "usageStatus": "reported", "status": 200, + "durationMs": 1, "totalTokens": 15000, "attempts": attempts, + ] + row.merge(changes, uniquingKeysWith: { _, latest in latest }) + return try #require(String(data: JSONSerialization.data(withJSONObject: row), encoding: .utf8)) + } + + private static func entry(attempts: [[String: Any]], changes: [String: Any] = [:]) throws -> OpenCodexUsageEntry { + try #require(OpenCodexUsageParser.parseLine(self.line(attempts: attempts, changes: changes))) + } +} diff --git a/Tests/CodexBarTests/GrokRecordedTurnCostTests.swift b/Tests/CodexBarTests/GrokRecordedTurnCostTests.swift new file mode 100644 index 0000000000..87ea3acd9b --- /dev/null +++ b/Tests/CodexBarTests/GrokRecordedTurnCostTests.swift @@ -0,0 +1,106 @@ +import Foundation +import Testing +@testable import CodexBarCore + +extension GrokCostUsagePricingTests { + @Test(arguments: [false, true]) + func `outer recorded spend is counted once with populated model usage`(hasNestedCosts: Bool) throws { + let summary = try self.recordedModelsSummary( + outerTicks: 10_000_000_000, + firstModelTicks: hasNestedCosts ? 4_000_000_000 : nil, + secondModelTicks: hasNestedCosts ? 6_000_000_000 : nil) + let day = try #require(summary.daily.first) + #expect(day.costUSD == 1) + #expect(day.totalTokens == 2000) + #expect(day.requestCount == 2) + #expect(day.estimatedRequestCount == 0) + #expect(day.unpricedRequestCount == 0) + #expect(summary.costProvenance == .vendorMetered) + if hasNestedCosts { + #expect(day.modelBreakdowns.compactMap(\.costUSD).reduce(0, +) == 1) + } else { + #expect(day.modelBreakdowns.allSatisfy { $0.costUSD == nil }) + } + } + + @Test(arguments: [false, true]) + func `outer recorded spend wins over inconsistent or partial nested costs`(partial: Bool) throws { + let summary = try self.recordedModelsSummary( + outerTicks: 10_000_000_000, + firstModelTicks: 4_000_000_000, + secondModelTicks: partial ? nil : 80_000_000_000) + let day = try #require(summary.daily.first) + #expect(day.costUSD == 1) + #expect(day.estimatedRequestCount == 0) + #expect(day.unpricedRequestCount == 0) + #expect(summary.costProvenance == .vendorMetered) + #expect(day.modelBreakdowns.allSatisfy { $0.costUSD == nil }) + #expect(day.modelBreakdowns.compactMap(\.totalTokens).reduce(0, +) == 2000) + } + + @Test(arguments: [false, true]) + func `missing or zero outer spend retains nested recorded costs`(zeroOuter: Bool) throws { + let summary = try self.recordedModelsSummary( + outerTicks: zeroOuter ? 0 : nil, + firstModelTicks: 4_000_000_000, + secondModelTicks: 6_000_000_000) + let day = try #require(summary.daily.first) + #expect(day.costUSD == 1) + #expect(day.modelBreakdowns.compactMap(\.costUSD).reduce(0, +) == 1) + #expect(day.estimatedRequestCount == 0) + #expect(summary.costProvenance == .vendorMetered) + } + + @Test + func `unattributed outer spend keeps the combined model cost unknown`() throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let now = try self.localDate(day: 20, hour: 12) + let outerOnly = self.usage( + input: 1000, + output: 0, + modelCalls: 1, + costUsdTicks: 10_000_000_000, + modelUsage: ["grok-4.6-build": self.modelUsage(input: 1000, output: 0, modelCalls: 1)]) + try self.writeUpdates( + [ + self.turn(timestamp: now, usage: outerOnly), + self.turn(timestamp: now.addingTimeInterval(1), usage: self.singleModelUsage( + input: 1000, output: 0, costUsdTicks: 4_000_000_000)), + ], + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: now.addingTimeInterval(60)) + let summary = try self.summarize(fixture: fixture, now: now.addingTimeInterval(120)) + let day = try #require(summary.daily.first) + #expect(abs((day.costUSD ?? 0) - 1.4) < 1e-12) + #expect(day.modelBreakdowns.first?.costUSD == nil) + #expect(day.totalTokens == 2000) + #expect(summary.costProvenance == .vendorMetered) + } + + private func recordedModelsSummary( + outerTicks: Int?, + firstModelTicks: Int?, + secondModelTicks: Int?) throws -> GrokLocalSessionSummary + { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let now = try self.localDate(day: 20, hour: 12) + let usage = self.usage( + input: 2000, + output: 0, + modelCalls: 2, + costUsdTicks: outerTicks, + modelUsage: [ + "grok-4.6-build": self.modelUsage( + input: 1000, output: 0, modelCalls: 1, costUsdTicks: firstModelTicks), + "grok-test-model": self.modelUsage( + input: 1000, output: 0, modelCalls: 1, costUsdTicks: secondModelTicks), + ]) + try self.writeUpdates( + [self.turn(timestamp: now, usage: usage)], + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: now.addingTimeInterval(60)) + return try self.summarize(fixture: fixture, now: now.addingTimeInterval(120)) + } +} diff --git a/Tests/CodexBarTests/GrokStatusMenuFallbackTests.swift b/Tests/CodexBarTests/GrokStatusMenuFallbackTests.swift new file mode 100644 index 0000000000..f8e91bfadb --- /dev/null +++ b/Tests/CodexBarTests/GrokStatusMenuFallbackTests.swift @@ -0,0 +1,125 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension StatusMenuTests { + @Test + func `grok live menu consumers prefer newer published local tokens over stale remote tokens`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.selectedMenuProvider = .grok + settings.costUsageEnabled = true + settings.costSummaryDisplayStyle = .both + + let metadata = try #require(ProviderRegistry.shared.metadata[.grok]) + settings.setProviderEnabled(provider: .grok, metadata: metadata, enabled: true) + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let now = Date() + let formatter = DateFormatter() + formatter.calendar = settings.costUsageBucketCalendar + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = settings.costUsageBucketCalendar.timeZone + formatter.dateFormat = "yyyy-MM-dd" + store._setTokenSnapshotForTesting(CostUsageTokenSnapshot( + sessionTokens: 77, + sessionCostUSD: 0.07, + last30DaysTokens: 77, + last30DaysCostUSD: 0.07, + daily: [ + CostUsageDailyReport.Entry( + date: formatter.string(from: now), + inputTokens: nil, + outputTokens: nil, + totalTokens: 77, + costUSD: 0.07, + modelsUsed: ["grok-4"], + modelBreakdowns: nil), + ], + updatedAt: now), provider: .grok) + let staleAt = now.addingTimeInterval(-60) + store.snapshots[UsageProvider.grok.instanceID] = UsageSnapshot( + primary: nil, + secondary: nil, + costUsage: CostUsageTokenSnapshot( + sessionTokens: 11, + sessionCostUSD: 0.01, + last30DaysTokens: 11, + last30DaysCostUSD: 0.01, + daily: [ + CostUsageDailyReport.Entry( + date: formatter.string(from: staleAt), + inputTokens: nil, + outputTokens: nil, + totalTokens: 11, + costUSD: 0.01, + modelsUsed: ["grok-4"], + modelBreakdowns: nil), + ], + updatedAt: staleAt), + updatedAt: staleAt) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let model = try #require(controller.menuCardModel(for: .grok)) + #expect(model.tokenUsage?.monthLine.contains("77") == true) + let historySnapshot = try #require(controller.tokenSnapshotForCostHistorySubmenu(provider: .grok)) + #expect(historySnapshot.last30DaysTokens == 77) + #expect(controller.makeCostHistorySubmenu(provider: .grok) != nil) + } + + @Test + func `grok override card does not inherit published local fallback`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.costUsageEnabled = true + + let metadata = try #require(ProviderRegistry.shared.metadata[.grok]) + settings.setProviderEnabled(provider: .grok, metadata: metadata, enabled: true) + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let now = Date(timeIntervalSince1970: 1_777_344_000) + store._setTokenSnapshotForTesting(CostUsageTokenSnapshot( + sessionTokens: 77, + sessionCostUSD: 0.07, + last30DaysTokens: 77, + last30DaysCostUSD: 0.07, + daily: [], + updatedAt: now), provider: .grok) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let model = controller.store.menuCardModel( + for: .grok, + context: .account(.init(snapshot: UsageSnapshot(primary: nil, secondary: nil, updatedAt: now))), + now: now) + #expect(model.tokenUsage == nil) + } +} diff --git a/Tests/CodexBarTests/GrokTokenSnapshotProjectionTests.swift b/Tests/CodexBarTests/GrokTokenSnapshotProjectionTests.swift index 4e9c40c78e..23eca4b1a7 100644 --- a/Tests/CodexBarTests/GrokTokenSnapshotProjectionTests.swift +++ b/Tests/CodexBarTests/GrokTokenSnapshotProjectionTests.swift @@ -4,7 +4,7 @@ import Testing @testable import CodexBar @MainActor -struct GrokTokenSnapshotProjectionTests { +struct GrokTokenSnapshotProjectionTests: GrokLocalSessionScannerTestSupport { @Test func `menu projections reuse published grok session data after the session tree disappears`() async throws { let root = FileManager.default.temporaryDirectory @@ -14,20 +14,16 @@ struct GrokTokenSnapshotProjectionTests { for index in 0..<192 { let directory = sessions.appendingPathComponent("session-\(index)", isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let file = directory.appendingPathComponent("signals.json") - try JSONSerialization.data(withJSONObject: [ - "contextTokensUsed": 5, - "totalTokensBeforeCompaction": 2, - "primaryModelId": "grok-4.6", - "modelsUsed": ["grok-4.6"], - ]).write(to: file) - try FileManager.default.setAttributes([.modificationDate: now], ofItemAtPath: file.path) + try self.writeUpdates( + [self.turn(timestamp: now, usage: self.singleModelUsage(input: 5, output: 2))], + to: directory.appendingPathComponent("updates.jsonl"), + modificationDate: now) } let store = Self.makeStore(environment: ["GROK_HOME": root.path]) let published = try #require(await store.loadGrokLocalTokenSnapshot(historyDays: 30)) #expect(published.last30DaysTokens == 1344) - #expect(published.last30DaysRequests == nil) + #expect(published.last30DaysRequests == 192) let providerSnapshot = UsageSnapshot( primary: nil, @@ -108,6 +104,66 @@ struct GrokTokenSnapshotProjectionTests { #expect(projected.last30DaysTokens == 85) } + @Test(arguments: [false, true], [false, true]) + func `native overflow stays unknown through remote and fallback menu projections`( + hasRemoteSnapshot: Bool, + hasUnknownDay: Bool) throws + { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let now = try self.localDate(day: 20, hour: 15) + let yesterday = try #require(Calendar.current.date(byAdding: .day, value: -1, to: now)) + var rows: [[String: Any]] = [self.turn(timestamp: yesterday, usage: [ + "inputTokens": Int.max, "outputTokens": 0, "totalTokens": Int.max, "costUsdTicks": 100, + ])] + if hasUnknownDay { + rows.append(self.turn(timestamp: yesterday, usage: [ + "inputTokens": 1, "outputTokens": 0, "totalTokens": 1, "costUsdTicks": 100, + ])) + } + rows.append(self.turn(timestamp: now, usage: [ + "inputTokens": 1, "outputTokens": 0, "totalTokens": 1, "costUsdTicks": 100, + ])) + try self.writeUpdates( + rows, + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: now) + let summary = try self.summarize(fixture: fixture, now: now) + let published = try #require(summary.toCostUsageTokenSnapshot(historyDays: 365)) + #expect(published.last30DaysTokens == nil) + let store = Self.makeStore(environment: [:]) + store.publishTokenSnapshot(published, for: .grok) + let remote = hasRemoteSnapshot + ? UsageSnapshot(primary: nil, secondary: nil, costUsage: published, updatedAt: now) : nil + let selected = try #require(store.tokenSnapshotForLiveProviderConsumer( + fromProviderSnapshot: remote, + provider: .grok, + historyDays: 30)) + #expect(selected.last30DaysTokens == nil) + #expect(!selected.historyCoverageIsEstablished) + #expect(selected.sessionTokens == 1) + #expect(selected.summary(forLastDays: 30).totalTokens == nil) + let menu = try #require(UsageMenuCardView.Model.tokenUsageSection( + provider: .grok, + enabled: true, + comparisonPeriodsEnabled: false, + snapshot: selected, + error: nil)) + #expect(!menu.monthLine.contains("tokens")) + let widget = try #require(UsageStore.widgetTokenUsageSummary(from: selected, provider: .grok)) + #expect(widget.last30DaysTokens == nil) + let dashboard = SpendDashboardModel.build( + inputs: [.init(provider: .grok, displayName: "Grok", snapshot: selected)], + requestedDays: 30, + now: now) + let group = try #require(dashboard.groups.first) + #expect(group.totalTokens == nil) + // Excluding the affected day restores the valid one-day value on both consumer paths. + let today = try #require(store.tokenSnapshotForLiveProviderConsumer( + fromProviderSnapshot: remote, provider: .grok, historyDays: 1)) + #expect(today.last30DaysTokens == 1) + } + private static func makeStore(environment: [String: String]) -> UsageStore { let suite = "GrokTokenSnapshotProjectionTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! @@ -126,30 +182,183 @@ struct GrokTokenSnapshotProjectionTests { environmentBase: environment) } + /// The projection recomputes tokens and requests from the days it keeps, so the cost has to follow. A + /// 30-day view rendering the 365-day dollar total beside a 30-day token count is the visible symptom. + @Test + func `grok projection recomputes window cost from the days it keeps`() throws { + let calendar = Calendar.current + let now = Date(timeIntervalSince1970: 1_787_079_600) + let recent = try #require(calendar.date(byAdding: .day, value: -2, to: now)) + let older = try #require(calendar.date(byAdding: .day, value: -120, to: now)) + let published = Self.snapshot( + daily: [ + Self.entry(date: Self.dayKey(older, calendar: calendar), tokens: 900, costUSD: 9), + Self.entry(date: Self.dayKey(recent, calendar: calendar), tokens: 100, costUSD: 1), + ], + updatedAt: now, + historyDays: 365, + costProvenance: .vendorMetered) + let store = Self.makeStore(environment: [:]) + + let narrowed = try #require(store.grokLocalTokenSnapshot( + from: UsageSnapshot(primary: nil, secondary: nil, costUsage: published, updatedAt: now), + historyDays: 30)) + + #expect(published.last30DaysCostUSD == 10) + #expect(narrowed.last30DaysTokens == 100) + #expect(narrowed.last30DaysCostUSD == 1) + } + + /// A narrowed window can drop every row of one kind, and its disclosure has to follow the rows it kept. + @Test + func `grok projection derives window provenance from the days it keeps`() throws { + let calendar = Calendar.current + let now = Date(timeIntervalSince1970: 1_787_079_600) + let recent = try #require(calendar.date(byAdding: .day, value: -2, to: now)) + let older = try #require(calendar.date(byAdding: .day, value: -120, to: now)) + let store = Self.makeStore(environment: [:]) + + let recordedRecently = Self.snapshot( + daily: [ + Self.entry( + date: Self.dayKey(older, calendar: calendar), + tokens: 900, + costUSD: 9, + estimatedRequestCount: 1), + Self.entry(date: Self.dayKey(recent, calendar: calendar), tokens: 100, costUSD: 1), + ], + updatedAt: now, + historyDays: 365, + costProvenance: .mixed) + let narrowedToRecorded = try #require(store.grokLocalTokenSnapshot( + from: UsageSnapshot(primary: nil, secondary: nil, costUsage: recordedRecently, updatedAt: now), + historyDays: 30)) + #expect(narrowedToRecorded.costProvenance == .vendorMetered) + + let estimatedRecently = Self.snapshot( + daily: [ + Self.entry(date: Self.dayKey(older, calendar: calendar), tokens: 900, costUSD: 9), + Self.entry( + date: Self.dayKey(recent, calendar: calendar), + tokens: 100, + costUSD: 1, + estimatedRequestCount: 1), + ], + updatedAt: now, + historyDays: 365, + costProvenance: .mixed) + let narrowedToEstimated = try #require(store.grokLocalTokenSnapshot( + from: UsageSnapshot(primary: nil, secondary: nil, costUsage: estimatedRecently, updatedAt: now), + historyDays: 30)) + #expect(narrowedToEstimated.costProvenance == .listPriceEstimate) + } + + @Test(arguments: [false, true], [false, true]) + func `live Grok consumers retain the source of a narrowed mixed publication`( + estimatedRecently: Bool, + hasRemoteSnapshot: Bool) throws + { + let published = try Self.mixedSnapshot(estimatedRecently: estimatedRecently) + let store = Self.makeStore(environment: [:]) + store.publishTokenSnapshot(published, for: .grok) + let remote = hasRemoteSnapshot ? UsageSnapshot( + primary: nil, + secondary: nil, + costUsage: Self.snapshot(daily: published.daily, updatedAt: published.updatedAt.addingTimeInterval(-60)), + updatedAt: published.updatedAt.addingTimeInterval(-60)) : nil + + let selected = try #require(store.tokenSnapshotForLiveProviderConsumer( + fromProviderSnapshot: remote, + provider: .grok, + historyDays: 30)) + + #expect(selected.last30DaysTokens == 100) + #expect(selected.last30DaysCostUSD == 1) + #expect(selected.updatedAt == published.updatedAt) + #expect(selected.costProvenance == (estimatedRecently ? .listPriceEstimate : .vendorMetered)) + let dashboard = SpendDashboardModel.build( + inputs: [.init(provider: .grok, displayName: "Grok", snapshot: selected)], + requestedDays: 30, + now: selected.updatedAt) + #expect(dashboard.groups.first?.provenance == selected.costProvenance) + } + + @Test(arguments: [false, true]) + func `new and shared Grok scans retain the source of the requested window`(estimatedRecently: Bool) async throws { + let published = try Self.mixedSnapshot(estimatedRecently: estimatedRecently) + let store = Self.makeStore(environment: [:]) + let metadata = try #require(ProviderRegistry.shared.metadata[.grok]) + store.settings.setProviderEnabled(provider: .grok, metadata: metadata, enabled: true) + store._test_grokLocalTokenScannerOverride = { _ in published } + + let first = try #require(await store.scanAndPublishGrokLocalTokenSnapshot(historyDays: 30)) + #expect(first.costProvenance == (estimatedRecently ? .listPriceEstimate : .vendorMetered)) + #expect(first.last30DaysCostUSD == 1) + + store.grokLocalTokenScanTask = Task { published } + defer { store.grokLocalTokenScanTask = nil } + let shared = try #require(await store.scanAndPublishGrokLocalTokenSnapshot(historyDays: 30)) + #expect(shared.costProvenance == (estimatedRecently ? .listPriceEstimate : .vendorMetered)) + #expect(shared == first) + } + + private static func mixedSnapshot(estimatedRecently: Bool) throws -> CostUsageTokenSnapshot { + let calendar = Calendar.current + let now = Date(timeIntervalSince1970: 1_787_079_600) + let recent = try #require(calendar.date(byAdding: .day, value: -2, to: now)) + let older = try #require(calendar.date(byAdding: .day, value: -120, to: now)) + return Self.snapshot( + daily: [ + Self.entry( + date: Self.dayKey(older, calendar: calendar), + tokens: 900, + costUSD: 9, + estimatedRequestCount: estimatedRecently ? nil : 1), + Self.entry( + date: Self.dayKey(recent, calendar: calendar), + tokens: 100, + costUSD: 1, + estimatedRequestCount: estimatedRecently ? 1 : nil), + ], + updatedAt: now, + historyDays: 365, + costProvenance: .mixed) + } + private static func snapshot( daily: [CostUsageDailyReport.Entry], - updatedAt: Date) -> CostUsageTokenSnapshot + updatedAt: Date, + historyDays: Int = 30, + costProvenance: CostProvenance = .unknown) -> CostUsageTokenSnapshot { - CostUsageTokenSnapshot( + let costs = daily.compactMap(\.costUSD) + return CostUsageTokenSnapshot( sessionTokens: daily.last?.totalTokens, sessionCostUSD: nil, last30DaysTokens: daily.compactMap(\.totalTokens).reduce(0, +), - last30DaysCostUSD: nil, - historyDays: 30, + last30DaysCostUSD: costs.isEmpty ? nil : costs.reduce(0, +), + historyDays: historyDays, + costProvenance: costProvenance, daily: daily, updatedAt: updatedAt) } - private static func entry(date: String, tokens: Int) -> CostUsageDailyReport.Entry { + private static func entry( + date: String, + tokens: Int, + costUSD: Double? = nil, + estimatedRequestCount: Int? = nil) -> CostUsageDailyReport.Entry + { CostUsageDailyReport.Entry( date: date, inputTokens: nil, outputTokens: nil, totalTokens: tokens, requestCount: 1, - costUSD: nil, + costUSD: costUSD, modelsUsed: ["grok-4.6"], - modelBreakdowns: nil) + modelBreakdowns: nil, + estimatedRequestCount: estimatedRequestCount) } private static func dayKey(_ date: Date, calendar: Calendar) -> String { diff --git a/Tests/CodexBarTests/GrokWindowProvenanceProofTests.swift b/Tests/CodexBarTests/GrokWindowProvenanceProofTests.swift new file mode 100644 index 0000000000..19a712ad92 --- /dev/null +++ b/Tests/CodexBarTests/GrokWindowProvenanceProofTests.swift @@ -0,0 +1,133 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@MainActor +struct GrokWindowProvenanceProofTests: GrokLocalSessionScannerTestSupport { + @Test(arguments: [false, true]) + func `completed turn logs retain their cost source through the live window`(estimatedRecently: Bool) throws { + let fixture = try self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let now = Date() + let recent = try #require(Calendar.current.date(byAdding: .day, value: -2, to: now)) + let older = try #require(Calendar.current.date(byAdding: .day, value: -120, to: now)) + let recordedUsage = self.usage( + input: 1000, + output: 0, + modelCalls: 1, + costUsdTicks: 10_000_000_000, + modelUsage: ["grok-4.6-build": self.modelUsage(input: 1000, output: 0, modelCalls: 1)]) + let estimatedUsage = self.singleModelUsage(input: 1000, output: 0) + try self.writeUpdates( + [ + self.turn(timestamp: older, usage: estimatedRecently ? recordedUsage : estimatedUsage), + self.turn(timestamp: recent, usage: estimatedRecently ? estimatedUsage : recordedUsage), + ], + to: fixture.session.appendingPathComponent("updates.jsonl"), + modificationDate: now) + let summary = try GrokLocalSessionScanner.summarize( + env: ["GROK_HOME": fixture.root.path], + lookbackDays: 365, + now: now, + modelsDevCatalog: Self.catalog()) + let published = try #require(summary.toCostUsageTokenSnapshot(historyDays: 365)) + let store = Self.makeStore() + store.publishTokenSnapshot(published, for: .grok) + // An available billing snapshot without local costs must still consume the newer local publication. + let remote = UsageSnapshot(primary: nil, secondary: nil, updatedAt: now.addingTimeInterval(-60)) + let selected = try #require(store.tokenSnapshotForLiveProviderConsumer( + fromProviderSnapshot: remote, provider: .grok, historyDays: 30)) + let expectedSource: CostProvenance = estimatedRecently ? .listPriceEstimate : .vendorMetered + let expectedCost = estimatedRecently ? 0.002 : 1 + + #expect(published.costProvenance == .mixed) + #expect(selected.historyDays == 30) + #expect(selected.last30DaysTokens == 1000) + #expect(abs((selected.last30DaysCostUSD ?? 0) - expectedCost) < 1e-12) + #expect(selected.costProvenance == expectedSource) + try Self.verifySurfaces(selected, days: 30) + // The dashboard retains the full scan and applies its own range/day selection after capture. + // Exercise that path too, rather than giving it an already narrowed snapshot. + for (days, selectedDay) in [(30, Date?.none), (365, Optional(recent))] { + let dashboard = SpendDashboardModel.build( + inputs: [.init(provider: .grok, displayName: "Grok", snapshot: published)], + requestedDays: days, + now: now, + selectedDay: selectedDay) + let group = try #require(dashboard.groups.first) + #expect(group.provenance == expectedSource) + print("fixture_dashboard_filter=\(selectedDay == nil ? "30-day-window" : "selected-day")") + print("fixture_dashboard_source=\(group.provenance.rawValue)") + } + print("fixture_full_source=\(published.costProvenance.rawValue)") + print("fixture_window_days=\(selected.historyDays)") + print("fixture_window_tokens=\(selected.last30DaysTokens ?? 0)") + print("fixture_window_cost_usd=\(selected.last30DaysCostUSD ?? 0)") + print("fixture_window_source=\(selected.costProvenance.rawValue)") + } + + @Test(.enabled( + if: ProcessInfo.processInfo.environment["CODEXBAR_LIVE_GROK_CATALOG_PROOF"] == "1", + "Set CODEXBAR_LIVE_GROK_CATALOG_PROOF=1 to scan local Grok sessions.")) + func `writes redacted live Grok window proof`() async throws { + let summary = try await GrokLocalSessionScanner.summarizeOffMainThread( + env: ProcessInfo.processInfo.environment, + lookbackDays: 365) + let published = try #require(summary.toCostUsageTokenSnapshot(historyDays: 365)) + let store = Self.makeStore() + store.publishTokenSnapshot(published, for: .grok) + let remote = UsageSnapshot( + primary: nil, secondary: nil, updatedAt: summary.scannedAt.addingTimeInterval(-60)) + + print("live_full_source=\(published.costProvenance.rawValue)") + for days in [1, 7, 30] { + let selected = try #require(store.tokenSnapshotForLiveProviderConsumer( + fromProviderSnapshot: remote, provider: .grok, historyDays: days)) + #expect(selected.historyDays == days) + #expect(selected.updatedAt == published.updatedAt) + if selected.daily.contains(where: { $0.costUSD != nil }) { + try Self.verifySurfaces(selected, days: days) + } else { + #expect(selected.costProvenance == .unknown) + } + print("live_window_days=\(days)") + print("live_window_tokens=\(selected.last30DaysTokens.map { String($0) } ?? "nil")") + print("live_window_cost_usd=\(selected.last30DaysCostUSD.map { String($0) } ?? "nil")") + print("live_window_source=\(selected.costProvenance.rawValue)") + print("live_window_priced_days=\(selected.daily.count { $0.costUSD != nil })") + } + } + + private static func verifySurfaces(_ snapshot: CostUsageTokenSnapshot, days: Int) throws { + let menu = try #require(UsageMenuCardView.Model.tokenUsageSection( + provider: .grok, + enabled: true, + comparisonPeriodsEnabled: false, + snapshot: snapshot, + error: nil)) + let dashboard = SpendDashboardModel.build( + inputs: [.init(provider: .grok, displayName: "Grok", snapshot: snapshot)], + requestedDays: days, + now: snapshot.updatedAt) + let group = try #require(dashboard.groups.first) + let row = try #require(group.providers.first) + let disclosure = "Grok CLI-recorded spend, list price where unrecorded · not a bill." + #expect(menu.hintLine == disclosure) + #expect(row.costDisclaimer == disclosure) + #expect(row.totalCost == snapshot.last30DaysCostUSD) + #expect(group.provenance == snapshot.costProvenance) + print("window_menu_disclosure=\(menu.hintLine ?? "nil")") + print("window_dashboard_source=\(group.provenance.rawValue)") + } + + private static func makeStore() -> UsageStore { + let settings = testSettingsStore(suiteName: "GrokWindowProvenanceProofTests-\(UUID().uuidString)") + return UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + } +} diff --git a/Tests/CodexBarTests/GrokXAISpendCatalogTests.swift b/Tests/CodexBarTests/GrokXAISpendCatalogTests.swift index c58a1a4f09..e838f6775c 100644 --- a/Tests/CodexBarTests/GrokXAISpendCatalogTests.swift +++ b/Tests/CodexBarTests/GrokXAISpendCatalogTests.swift @@ -8,8 +8,56 @@ struct GrokXAISpendCatalogTests { func `grok and xai publish through the snapshot-backed spend catalog`() { #expect(UsageStore.tokenCostRequiresProviderSnapshot(.grok)) #expect(UsageStore.tokenCostRequiresProviderSnapshot(.xai)) - #expect(ProviderDescriptorRegistry.descriptor(for: .grok).tokenCost.supportsTokenCost) + let grokTokenCost = ProviderDescriptorRegistry.descriptor(for: .grok).tokenCost + #expect(grokTokenCost.supportsTokenCost) #expect(ProviderDescriptorRegistry.descriptor(for: .xai).tokenCost.supportsTokenCost) + #expect(grokTokenCost.noDataMessage() == + "Grok totals come from local Grok CLI session logs. Costs use the spend the CLI recorded, " + + "or public list prices where it recorded none. Neither is a bill.") + #expect(grokTokenCost.menuHintLines == [.estimate]) + #expect(grokTokenCost.showsHintInProviderDetails) + #expect(grokTokenCost + .estimateDisclaimer == "Grok CLI-recorded spend, list price where unrecorded · not a bill.") + #expect(grokTokenCost.chartEstimateDisclaimer == .estimate) + } + + @MainActor + @Test + func `populated Grok surfaces disclose that the cost is not a bill`() throws { + let now = Date(timeIntervalSince1970: 1_787_587_200) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 1100, + sessionCostUSD: 0.0023, + last30DaysTokens: 1100, + last30DaysCostUSD: 0.0023, + historyDays: 7, + costProvenance: .listPriceEstimate, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-08-24", + inputTokens: 1000, + outputTokens: 100, + totalTokens: 1100, + costUSD: 0.0023, + modelsUsed: ["grok-4.6"], + modelBreakdowns: nil), + ], + updatedAt: now) + let section = try #require(UsageMenuCardView.Model.tokenUsageSection( + provider: .grok, + enabled: true, + comparisonPeriodsEnabled: false, + snapshot: snapshot, + error: nil)) + let dashboard = SpendDashboardModel.build( + inputs: [.init(provider: .grok, displayName: "Grok", snapshot: snapshot)], + requestedDays: 7, + now: now) + let row = try #require(dashboard.groups.first?.providers.first) + + #expect(section.hintLine == "Grok CLI-recorded spend, list price where unrecorded · not a bill.") + #expect(row.totalCost == 0.0023) + #expect(row.costDisclaimer == "Grok CLI-recorded spend, list price where unrecorded · not a bill.") } @Test(.enabled( @@ -23,14 +71,39 @@ 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) + // Which source priced the corpus depends on what the CLI recorded, so assert the contract that + // holds for every corpus: a priced window names a source, an unpriced one claims none. + if pricedDayCount > 0 { + #expect([.vendorMetered, .mixed, .listPriceEstimate].contains(snapshot.costProvenance)) + } else { + #expect(snapshot.costProvenance == .unknown) + } + #expect(pricedDayCount <= tokenDayCount) + if tokenDayCount > 0 { + if pricedDayCount > 0 { + 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 93f4bf5e8d..c65895e747 100644 --- a/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift +++ b/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift @@ -29,6 +29,62 @@ 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 remains token only with an injected xai catalog`() throws { + let catalog = try Self.pricingCatalog() + let snapshot = try Self.pricingSnapshot(provider: "xai", model: "grok-4.6", catalog: catalog) + + #expect(snapshot.daily.first?.totalTokens == 1100) + #expect(snapshot.daily.first?.costUSD == nil) + #expect(snapshot.daily.first?.unpricedRequestCount == 1) + #expect(snapshot.last30DaysCostUSD == nil) + } + + @Test func `bare openai model keeps its pre qualification catalog price`() throws { + 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)) @@ -536,6 +592,91 @@ struct OpenCodexUsageFanOutTests { ]), ]) } + + private static func pricingSnapshot( + provider: String, + model: String, + catalog: ModelsDevCatalog) throws -> CostUsageTokenSnapshot + { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let now = Date(timeIntervalSince1970: 1_787_270_400) + return OpenCodexUsageAggregator.snapshot( + entries: [ + OpenCodexUsageEntry( + requestID: "pricing-\(provider)", + timestamp: now, + provider: provider, + model: model, + usageStatus: .reported, + usage: OpenCodexTokenUsage( + inputTokens: 1000, + outputTokens: 100, + cacheReadInputTokens: 200, + totalTokens: 1100), + totalTokens: 1100), + ], + now: now, + historyDays: 7, + calendar: calendar, + modelsDevCatalog: catalog) + } + + private static func xaiEntries(now: Date) -> [OpenCodexUsageEntry] { + [ + OpenCodexUsageEntry( + requestID: "xai-1", + timestamp: now, + provider: "xai", + model: "grok-4.6", + usageStatus: .reported, + usage: OpenCodexTokenUsage(inputTokens: 100, outputTokens: 20, totalTokens: 120), + totalTokens: 120), + OpenCodexUsageEntry( + requestID: "xai-2", + timestamp: now, + provider: "xai", + model: "xai/grok-4.6", + usageStatus: .reported, + usage: OpenCodexTokenUsage(inputTokens: 60, outputTokens: 20, totalTokens: 80), + totalTokens: 80), + ] + } + + private static func pricingCatalog() throws -> ModelsDevCatalog { + let json = """ + { + "xai": { + "id": "xai", + "models": { + "grok-4.6": { + "id": "grok-4.6", + "cost": { "input": 2, "output": 6, "cache_read": 0.5 } + } + } + }, + "openai": { + "id": "openai", + "models": { + "gpt-5.6-sol": { + "id": "gpt-5.6-sol", + "cost": { "input": 1, "output": 4, "cache_read": 0.25 } + } + } + }, + "kimi": { + "id": "kimi", + "models": { + "k3[1m]": { + "id": "k3[1m]", + "cost": { "input": 9, "output": 19 } + } + } + } + } + """ + return try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8)) + } } private enum OpenCodexUsageSnapshotReference { diff --git a/Tests/CodexBarTests/OpenCodexUsagePricingTests.swift b/Tests/CodexBarTests/OpenCodexUsagePricingTests.swift new file mode 100644 index 0000000000..675f8386c5 --- /dev/null +++ b/Tests/CodexBarTests/OpenCodexUsagePricingTests.swift @@ -0,0 +1,131 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct OpenCodexUsagePricingTests { + private static let now = Date(timeIntervalSince1970: 1_787_270_400) + private static let calendar = CostUsageBucketTimeZone.calendar(identifier: "UTC") + + @Test func `application overlay preserves bare model precedence and cached token accounting`() throws { + let entry = Self.entry(provider: "openai", model: "gpt-5.4", cached: true) + let bare = CostUsageCustomPricing.Rates(input: 3, output: 4, cacheRead: 1, cacheWrite: 2) + let qualified = CostUsageCustomPricing.Rates(input: 9, output: 9, cacheRead: 9, cacheWrite: 9) + let cases: [([String: CostUsageCustomPricing.Rates], Double)] = [ + (["gpt-5.4": bare], 0.000030), + (["gpt-5.4": bare, "openai/gpt-5.4": qualified], 0.000030), + (["openai/gpt-5.4": qualified], 0.000108), + (["gpt-5.4": .init(input: 0, output: 0, cacheRead: 0, cacheWrite: 0)], 0), + ] + for (entries, expected) in cases { + let snapshot = Self.snapshot(entry, overlay: .init(entries: entries, fingerprint: "overlay")) + let cost = try #require(snapshot.last30DaysCostUSD) + #expect(abs(cost - expected) < 0.000000000001) + } + } + + @Test func `incomplete bare application override stays unknown ahead of qualified and catalog prices`() { + let snapshot = Self.snapshot( + Self.entry(provider: "openai", model: "gpt-5.4"), + overlay: .init(entries: [ + "gpt-5.4": .init(input: 3), + "openai/gpt-5.4": .init(input: 9, output: 9), + ], fingerprint: "missing-output")) + #expect(snapshot.last30DaysTokens == 5) + #expect(snapshot.last30DaysCostUSD == nil) + } + + @Test func `snapshot custom prices keep precedence over the application overlay`() throws { + let snapshot = Self.snapshot( + Self.entry(provider: "openai", model: "gpt-5.4"), + pricing: .init(entries: ["gpt-5.4": .init(input: 2, output: 2)], fingerprint: "snapshot"), + overlay: .init(entries: ["gpt-5.4": .init(input: 9, output: 9)], fingerprint: "application")) + let cost = try #require(snapshot.last30DaysCostUSD) + #expect(abs(cost - 0.000010) < 0.000000000001) + } + + @Test func `standalone xai custom estimates survive cache loading without subscription attribution`() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let log = root.appendingPathComponent("usage.jsonl") + let cache = root.appendingPathComponent("cache") + let pricingFile = root.appendingPathComponent("custom-pricing.json") + let model = "grok-fictional-priced" + let timestamp = Int(Self.now.timeIntervalSince1970 * 1000) + for source in ["", "xai-api-key"] { + let attempts = source.isEmpty ? "[]" : """ + [{"ordinal":1,"provider":"xai","model":"\(model)","sendCount":1,\ + "credentialSource":"\(source)","usageStatus":"reported",\ + "usage":{"inputTokens":3,"outputTokens":2,"totalTokens":5}}] + """ + try """ + {"requestId":"standalone","timestamp":\(timestamp),"provider":"xai","model":"\(model)",\ + "usageStatus":"reported","usage":{"inputTokens":3,"outputTokens":2,"totalTokens":5},\ + "attempts":\(attempts)} + + """.write(to: log, atomically: true, encoding: .utf8) + for key in [model, "xai/\(model)"] { + for rate in [0.0, 2.0] { + try """ + {"\(key)":{"input":\(rate),"output":\(rate)}} + """.write(to: pricingFile, atomically: true, encoding: .utf8) + let pricing = CostUsageCustomPricing.load(fileURL: pricingFile) + let store = OpenCodexUsageStore(cacheRoot: cache) + let entries = try store.loadEntries(logURL: log) + let entry = try #require(entries.first) + #expect(entry.credentialSource == nil) + #expect(OpenCodexUsageFanOut.snapshotsBySubscription( + entries: entries, + now: Self.now, + historyDays: 7, + calendar: Self.calendar, + customPricing: pricing).isEmpty) + let loaded = try store.loadSnapshot( + logURL: log, + now: Self.now, + historyDays: 7, + calendar: Self.calendar, + customPricing: pricing) + let application = Self.snapshot(entry, overlay: pricing) + for snapshot in [loaded, application] { + let cost = try #require(snapshot.last30DaysCostUSD) + #expect(abs(cost - rate * 5 / 1_000_000) < 0.000000000001) + #expect(snapshot.costProvenance == .listPriceEstimate) + } + #expect(Self.snapshot(entry).last30DaysCostUSD == nil) + } + } + } + } + + private static func entry(provider: String, model: String, cached: Bool = false) -> OpenCodexUsageEntry { + OpenCodexUsageEntry( + requestID: "pricing", + timestamp: self.now, + provider: provider, + model: model, + usageStatus: .reported, + usage: OpenCodexTokenUsage( + inputTokens: cached ? 10 : 3, + outputTokens: 2, + cacheReadInputTokens: cached ? 3 : nil, + cacheCreationInputTokens: cached ? 2 : nil, + totalTokens: cached ? 12 : 5), + totalTokens: cached ? 12 : 5) + } + + private static func snapshot( + _ entry: OpenCodexUsageEntry, + pricing: CostUsageCustomPricing = .empty, + overlay: CostUsageCustomPricing = .empty) -> CostUsageTokenSnapshot + { + OpenCodexUsageAggregator.snapshot( + entries: [entry], + now: self.now, + historyDays: 7, + calendar: self.calendar, + customPricing: pricing, + modelsDevCatalog: ModelsDevCatalog(providers: [:]), + customPricingOverlay: overlay) + } +} diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 7f6769d308..2467dcc4d4 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -962,55 +962,55 @@ struct ProviderArchitectureGatekeeperTests { reason: "This observation touchpoint reads a fixed provider field so UI invalidation tracks that setting."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 432, + line: 438, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 434, + line: 440, anchor: "modelProviderName: ProviderDescriptorRegistry.descriptor(for: .codex).metadata.displayName,", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 520, + line: 526, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 523, + line: 529, anchor: "modelProviderName: ProviderDescriptorRegistry.descriptor(for: .codex)", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 603, + line: 609, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 647, + line: 653, anchor: "let providerName = store.metadata(for: .codex).displayName", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 1717, + line: 1722, 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: 1746, + line: 1751, 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: 1763, + line: 1768, anchor: "if sourceID.hasPrefix(\"codex:\") { return .codex }", expectedProviderIDs: ["codex"], reason: "This publication projection maps stable Codex account source IDs back to their provider family."), @@ -1148,7 +1148,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: 1511, + line: 1507, 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."), @@ -1330,19 +1330,19 @@ struct ProviderArchitectureGatekeeperTests { reason: "Claude widget quota ownership uses the selected Claude account's isolated snapshot key."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore.swift", - line: 1069, + line: 1057, 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: 1171, + line: 1159, 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: 1175, + line: 1163, anchor: "provider: .claude,", expectedProviderIDs: ["claude"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), @@ -1824,7 +1824,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+Costs.swift", - line: 233, + line: 226, anchor: "} else if provider == .mistral,", expectedProviderIDs: ["mistral"], expectedReferenceCount: 1, @@ -1832,7 +1832,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+Costs.swift", - line: 497, + line: 490, anchor: "if style == .claude {", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -2383,7 +2383,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 636, + line: 642, anchor: "(providers.contains(.codex) && settings.codexLocalSessionCostLedgerEnabled)", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2418,12 +2418,21 @@ struct ProviderArchitectureGatekeeperTests { line: 292, anchor: "for provider in providers where provider != .codex {", expectedProviderIDs: ["codex", "grok"], - expectedReferenceCount: 7, - expectedReferenceFingerprint: ["codex@0", "grok@3", "grok@5", "grok@6", "grok@10", "grok@11", "grok@14"], + expectedReferenceCount: 8, + expectedReferenceFingerprint: [ + "codex@0", + "grok@3", + "grok@4", + "grok@5", + "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: 698, + line: 704, anchor: "if providers.contains(.codex) {", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2431,7 +2440,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 726, + line: 732, anchor: "if providers.contains(.codex) {", expectedProviderIDs: ["codex"], expectedReferenceCount: 3, @@ -2439,7 +2448,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: 1790, + line: 1795, anchor: "guard input.provider == .codex,", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2455,7 +2464,15 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardModel.swift", - line: 1270, + line: 561, + anchor: "let provenance = if summary.input.provider == .grok {", + expectedProviderIDs: ["grok"], + expectedReferenceCount: 1, + expectedReferenceFingerprint: ["grok@0"], + reason: "Grok owns how retained row coverage distinguishes recorded spend from list-price estimates."), + AllowedProviderConstruct( + path: "Sources/CodexBar/SpendDashboardModel.swift", + line: 1284, anchor: "guard provider == .mistral || provider == .openrouter || provider == .xai else { return displayCalendar }", expectedProviderIDs: ["mistral", "openrouter", "xai"], expectedReferenceCount: 3, @@ -2847,7 +2864,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: 120, + line: 137, anchor: "let extraWindows = provider == .claude", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -2855,7 +2872,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: 147, + line: 164, anchor: "guard provider == .claude else { return }", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -2863,7 +2880,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: 209, + line: 226, anchor: "guard provider != .claude || window != .session || Self.isSessionWindow(rateWindow) else { return }", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -2984,15 +3001,15 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 1360, - anchor: "if provider == .gemini, Self.isGeminiConsumerTierDeprecationError(error) {", - expectedProviderIDs: ["claude", "gemini"], - expectedReferenceCount: 2, - expectedReferenceFingerprint: ["gemini@0", "claude@12"], + line: 1353, + anchor: "if provider == .grok {", + expectedProviderIDs: ["claude", "gemini", "grok"], + expectedReferenceCount: 3, + expectedReferenceFingerprint: ["grok@0", "gemini@10", "claude@22"], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 1404, + line: 1407, anchor: "if provider == .claude,", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3000,7 +3017,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: 1420, + line: 1423, anchor: "if provider == .claude,", expectedProviderIDs: ["claude"], expectedReferenceCount: 5, @@ -3008,7 +3025,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: 1512, + line: 1508, anchor: "cached.cacheKey == self.tokenAccountSnapshotCacheKey(provider: .claude, account: currentAccount)", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3234,7 +3251,7 @@ struct ProviderArchitectureGatekeeperTests { line: 534, anchor: "case .openai:", expectedProviderIDs: ["grok", "mistral", "openai", "opencodego", "openrouter", "xai"], - expectedReferenceCount: 12, + expectedReferenceCount: 7, expectedReferenceFingerprint: [ "openai@0", "mistral@2", @@ -3242,17 +3259,12 @@ struct ProviderArchitectureGatekeeperTests { "openrouter@12", "xai@14", "grok@16", - "grok@27", - "mistral@27", - "openai@27", - "opencodego@27", - "openrouter@27", - "xai@27", + "grok@26", ], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 603, + line: 657, anchor: "self.tokenFailureGates[.codex]?.reset()", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 2, @@ -3301,7 +3313,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 374, + line: 367, anchor: "let sessionLabel = if provider == .bedrock || provider == .mistral {", expectedProviderIDs: ["bedrock", "codex", "mistral"], expectedReferenceCount: 4, @@ -3309,7 +3321,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 446, + line: 439, anchor: "if provider == .codex {", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3317,7 +3329,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 465, + line: 458, anchor: "if provider == .claude,", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3325,7 +3337,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 404, + line: 397, anchor: "if provider == .cursor, snapshot.detailRow(label: \"Request quota\") != nil {", expectedProviderIDs: ["alibabatokenplan", "amp", "crof", "cursor", "doubao", "grok", "ollama"], expectedReferenceCount: 7, @@ -3341,7 +3353,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 478, + line: 477, anchor: "if provider == .antigravity,", expectedProviderIDs: ["alibabatokenplan", "amp", "antigravity"], expectedReferenceCount: 4, @@ -3349,7 +3361,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 520, + line: 513, anchor: "if provider == .cursor {", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3357,7 +3369,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "Cursor Grok Bot weekly included usage is a named extraRateWindow on the shared widget projection."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 533, + line: 526, anchor: "if provider == .claude, self.settings.claudeModelScopedWeeklyUsageVisible {", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3365,7 +3377,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "Claude's opt-in widget projection adds provider-owned model-scoped weekly quota rows."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 547, + line: 540, anchor: "if provider == .kimi {", expectedProviderIDs: ["kimi"], expectedReferenceCount: 1, @@ -3373,7 +3385,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 629, + line: 633, anchor: "self.metadata(for: .codex).browserCookieOrder ?? Browser.defaultImportOrder", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3381,7 +3393,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 681, + line: 685, anchor: "self.providerSpecs[provider]?.style ?? .codex", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3389,7 +3401,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 714, + line: 718, anchor: "guard provider != .codex else { return true }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3397,7 +3409,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1043, + line: 1031, anchor: "let claudeDebugConfiguration: ClaudeDebugLogConfiguration? = if provider == .claude {", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3405,7 +3417,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1066, + line: 1054, anchor: "let deepSeekHasTokenAccount = self.settings.selectedTokenAccount(for: .deepseek) != nil", expectedProviderIDs: ["deepseek"], expectedReferenceCount: 1, @@ -3413,7 +3425,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1123, + line: 1111, anchor: "case .amp:", expectedProviderIDs: ["amp", "deepseek", "notion", "ollama", "warp"], expectedReferenceCount: 7, @@ -3429,7 +3441,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1178, + line: 1166, anchor: "let claudeSettings = snapshot.claude ?? ProviderSettingsSnapshot.ClaudeProviderSettings(", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3778,13 +3790,13 @@ struct ProviderArchitectureGatekeeperTests { path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift", line: 466, anchor: "static let codexModelsDevProviderID = \"openai\"", - expectedProviderIDs: ["deepseek", "openai", "opencode"], - expectedReferenceCount: 4, - expectedReferenceFingerprint: ["openai@0", "deepseek@7", "openai@10", "opencode@11"], + expectedProviderIDs: ["deepseek", "openai", "opencode", "xai"], + expectedReferenceCount: 5, + expectedReferenceFingerprint: ["openai@0", "deepseek@3", "openai@6", "opencode@7", "xai@13"], reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift", - line: 531, + line: 539, anchor: "if self.codex[trimmed] != nil {", expectedProviderIDs: ["codex"], expectedReferenceCount: 2, @@ -3792,7 +3804,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: 574, + line: 582, anchor: "if self.claude[base] != nil {", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3800,7 +3812,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: 609, + line: 617, anchor: "let bundled = lookup.pricing.providerID == self.codexModelsDevProviderID ? self.codex[key] : nil", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3808,7 +3820,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: 643, + line: 651, anchor: "guard let pricing = self.codex[key] else { return nil }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3816,7 +3828,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: 803, + line: 811, anchor: "guard let pricing = self.claude[key] else { return nil }", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, diff --git a/Tests/CodexBarTests/SpendDashboardGrokFreshnessTests.swift b/Tests/CodexBarTests/SpendDashboardGrokFreshnessTests.swift new file mode 100644 index 0000000000..737795d212 --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardGrokFreshnessTests.swift @@ -0,0 +1,68 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct SpendDashboardGrokFreshnessTests { + @Test + func `dashboard capture prefers newer local publication over preserved remote snapshot`() async throws { + let settings = testSettingsStore(suiteName: "SpendDashboardGrokFreshnessTests-preserved-remote") + settings.costUsageEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .grok) + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + let now = Date() + let staleRemote = Self.snapshot(tokens: 77, cost: 0.77, updatedAt: now.addingTimeInterval(-60)) + let newerLocal = Self.snapshot(tokens: 100, cost: 1, updatedAt: now) + store._setSnapshotForTesting( + UsageSnapshot(primary: nil, secondary: nil, costUsage: staleRemote, updatedAt: staleRemote.updatedAt), + provider: .grok) + store._setTokenSnapshotForTesting(newerLocal, provider: .grok) + + let request = await SpendDashboardSource.makeRequest( + settings: settings, + store: store, + mode: .captureOnly, + now: now) + let captured = try #require(request.capturedInputs.first(where: { $0.provider == .grok })) + + #expect(captured.snapshot.last30DaysTokens == 100) + #expect(captured.snapshot.last30DaysCostUSD == 1) + #expect(captured.snapshot.updatedAt == now) + } + + private static func snapshot( + tokens: Int, + cost: Double, + updatedAt: Date) -> CostUsageTokenSnapshot + { + let date = Calendar.current.dateComponents([.year, .month, .day], from: updatedAt) + let day = String(format: "%04d-%02d-%02d", date.year ?? 1970, date.month ?? 1, date.day ?? 1) + return CostUsageTokenSnapshot( + sessionTokens: tokens, + sessionCostUSD: cost, + last30DaysTokens: tokens, + last30DaysCostUSD: cost, + currencyCode: "USD", + daily: [ + CostUsageDailyReport.Entry( + date: day, + inputTokens: nil, + outputTokens: nil, + totalTokens: tokens, + costUSD: cost, + modelsUsed: ["grok-4"], + modelBreakdowns: nil), + ], + updatedAt: updatedAt) + } +} diff --git a/docs/evidence/grok-opencodex-producer-2026-09-05.md b/docs/evidence/grok-opencodex-producer-2026-09-05.md new file mode 100644 index 0000000000..c5f265f4f3 --- /dev/null +++ b/docs/evidence/grok-opencodex-producer-2026-09-05.md @@ -0,0 +1,73 @@ +# Grok / OpenCodex producer-to-dashboard evidence + +The captured ledger at `Tests/CodexBarTests/Fixtures/GrokOpenCodex/usage.jsonl` was written by the production OpenCodex server, request handlers, and durable usage logger at commit `146ed679c9633e5d68726217fcadc8e0b107339b` ([producer PR #3642](https://github.com/lidge-jun/opencodex/pull/3642)). Bun version: 1.4.0. + +Two localhost HTTP requests went through that server. OAuth credentials and the xAI/Grok and identity-provider responses came from OpenCodex's isolated upstream fixtures. Unexpected external requests were rejected. This capture exercises real routing, OAuth replay, native Chat dispatch, logging, file import, cache persistence, and dashboard projection; it is not evidence of live vendor authentication or billing. All identity labels belong to artificial fixture accounts. No user credentials or conversation text appear in the captured ledger. + +## Producer landing + +OpenCodex subsequently integrated this contract through [attributed carry PR #3762](https://github.com/lidge-jun/opencodex/pull/3762), merged into `dev` as `f00f2bcaea251ebe7ad4de9e38337b4be0ccee47` on September 6. That carry preserves the original contribution credit and adds resolved-adapter resealing and native Chat key-pool rotation coverage. The original producer PR #3642 was then closed. + +The capture below remains pinned to `146ed679c9633e5d68726217fcadc8e0b107339b` for byte-for-byte reproduction. It proves the recorded contract and consumer import shown here; it is not a new capture of the carry commit or a claim about a released producer version. + +## Producer result + +The unmodified production handlers persisted these physical attempts: + +| Inbound request | Resolved adapter | Persisted credential source | Upstream sends | Reported tokens | +| --- | --- | --- | ---: | ---: | +| Responses, OAuth 401 then success | `openai-responses` | `grok-oauth` | 2 | 5 | +| Native Chat Completions, API key | `openai-chat` | `xai-api-key` | 1 | 5 | + +The source stamp comes after resolved transport/adapter selection in Responses, and from `activeProvider` when native Chat builds or rebuilds its outbound request. OpenCodex's persistence normalizer retains only the two fixed source values on `xai` attempts. The captured bytes retain the full production ledger shape, including attempts, recovery kinds, and route-decision metadata. + +Initial producer run: 2 tests passed, 23 assertions, zero failures. Captured ledger SHA-256: + +``` +ef6d8758b40910f6e5993d5b5a105a2ad2834c6c1bd0565ab87b61cf091c4978 +``` + +## CodexBar import result + +`GrokOpenCodexUsageTests` copies those exact bytes to an isolated `OPENCODEX_HOME/usage.jsonl`, supplies only an isolated cache directory, and calls the production dashboard disk loader. It supplies no injected entries or entry-loader closure. A second call constructs a new store and reads the persisted cache. A third import keeps only the exact producer-written API-key line. + +Captured terminal output from the passing consumer test: + +``` +producer_capture_sha256=ef6d8758b40910f6e5993d5b5a105a2ad2834c6c1bd0565ab87b61cf091c4978 +producer_log_rows=2 total_reported_tokens=10 grok_oauth_tokens=5 +producer_import_dashboard_tokens=5 cache_reopen_bytes=0 +producer_api_key_only_subscription_rows=0 +``` + +The production dashboard model displays 5 tokens. The API-key attempt contributes none to the Grok row. Existing focused regressions separately verify estimated dollars, mixed recorded/estimated date filters, malformed and historic records, and cache upgrades. + +## In-flight native scan cancellation + +`CostUsageScanExecutorTests` runs the Grok scanner through the actual executor on an isolated serial queue. A barrier after 32 cancellation checks confirms chunk parsing has started; the test then cancels the awaiting Swift task and queues another scan. Cancellation returns `CancellationError`, the second scan runs, and parsing stops before the full 40,000-line corpus. The test checks a one-second release bound. + +Observed result from the passing run: + +``` +grok_cancel_decoded_lines=4874/40000 +grok_cancel_queue_release=0.001392625 seconds +``` + +This is a controlled executor/scanner measurement, not an app-wide latency guarantee. Both production async Grok entry points now pass the executor callback through discovery, JSONL reads, and per-turn aggregation. Cancelled partial parses remain uncacheable and cannot establish complete history. + +## Reproduce + +Use a clean OpenCodex checkout at the pinned commit with its locked dependencies installed. The capture helper refuses another commit or a dirty checkout. It copies the upstream fixture runner, adds ledger export before fixture teardown, and rejects unexpected upstream fetches; production OpenCodex source is unchanged. + +```sh +python3 Scripts/capture_grok_opencodex_proof.py \ + --opencodex-root /path/to/pinned/opencodex \ + --output-dir /tmp/new-grok-producer-capture + +CODEXBAR_SUPPRESS_TEST_KEYCHAIN_ACCESS=1 \ +CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS=0 \ +CODEXBAR_TEST_CODEX_FILE_ISOLATION=1 \ +swift test --filter 'GrokOpenCodexUsageTests|CostUsageScanExecutorTests' +``` + +A second capture using the committed helper independently passed with the same sources, send counts, and token totals. Its SHA-256 was `f43560d58af9805185607a7643f836af4fb0bda15102678a9742b0977785f8d4`. Timestamps, request IDs, route-decision IDs, durations, and anonymous fixture labels vary between captures, so reproduction hashes are expected to differ. The checked-in consumer fixture is deliberately pinned to the initial bytes. diff --git a/docs/grok.md b/docs/grok.md index 7140b3f002..bb215b5045 100644 --- a/docs/grok.md +++ b/docs/grok.md @@ -146,10 +146,31 @@ The grok.com billing gRPC-web endpoint remains a best-effort fallback. unknown-usage retry adopts only the validated active-period shape described above. This keeps billing visible when `grok agent stdio` returns `Method not found`. -5) **Local session signals** (informational fallback) - - Walks `~/.grok/sessions///signals.json` files (last 30 days). - - Aggregates `totalTokensBeforeCompaction`, `contextTokensUsed`, `modelsUsed`, - and the most recent session timestamp. +5) **Local completed-turn history** (informational fallback and Usage & Spend) + - Streams completed `turn_completed` records from + `~/.grok/sessions///updates.jsonl` over the requested + history window (up to 365 days). + - Aggregates the recorded per-turn token usage, model breakdown, request count, + and timestamps. Cost comes from the `costUsdTicks` the CLI recorded for each turn + (ticks / 1e10 = USD), which already carries the price tier and any promotional + rate. Entries that record no ticks fall back to public xAI list prices and are + counted as estimated, so a window reports whether it was recorded, estimated, or + a mix of both. Neither figure is a Grok bill. + - A positive outer `usage.costUsdTicks` is the authoritative turn total and is + counted once, even when `modelUsage` is populated. Nested recorded costs are used + for model dollars only when every model has ticks and their sum matches that total; + otherwise model tokens remain available while model dollars stay unknown. Without + a positive outer total, each model uses its recorded ticks or the list-price fallback. + - Live menu and dashboard publications, including reused in-flight scan results, + derive the cost source from the days retained in the requested window. A shorter + window containing only CLI-recorded turns stays recorded even when older history + includes list-price estimates; an estimate-only window stays estimated. + Usage & Spend applies the same source calculation when filtering its retained + 365-day input by history range or by a selected day. + - Reads only a bounded tail of each growing JSONL file, caps individual records + and retained parsed turns, bounds session-tree discovery, and reports history as incomplete if a bound is hit. + - Uses `signals.json` only as a metadata fallback for sessions with no completed + turns; context-window occupancy is never counted as consumed tokens. ## OAuth credentials @@ -178,6 +199,28 @@ The grok.com billing gRPC-web endpoint remains a best-effort fallback. relabel the result with the new account. Cookie usage stays separate from this captured account; local session scanning and CLI behavior are unchanged. +## OpenCodex usage + +With **Include OpenCodex usage logs** enabled (off by default), **Usage & Spend** +adds reported Grok OAuth attempts from OpenCodex to the Grok row. This requires an +OpenCodex version that writes `attempts[].credentialSource: "grok-oauth"` from the +resolved upstream transport. API-key attempts, historic records without provenance, +unreported or estimated token counts, and locally answered requests stay excluded. +Current credentials, inbound API keys, and model names never backfill attribution. + +Each physical attempt contributes its own reported token counts, including a metered +attempt before a combo switches providers. The parent aggregate is not counted again. +OpenCodex has no recorded dollar amount: its costs use public list prices and remain +estimates, with separate coverage when combined with native CLI-recorded spend. +Missing token classes or unknown prices retain tokens without inventing a dollar value. +The Grok menu continues to use native CLI logs; this opt-in integration is for +**Usage & Spend**. Neither source is a subscription invoice. + +Standalone OpenCodex reports preserve explicit custom prices for xAI records, including +API-key and historic usage. Those user-configured estimates do not establish Grok +subscription attribution. Without a matching override, raw xAI records remain token-only. +Application price overrides retain bare-model key precedence before provider-qualified +keys; missing rate fields remain unknown rather than falling back to catalog prices. ## JSON-RPC contract @@ -242,30 +285,53 @@ 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 visits at most 4,096 session-tree entries, then considers at most 256 recent +sessions, 256 MiB, and 100,000 turns; the +process-wide LRU parse cache retains at most 64 files or 50,000 turns. If a bound +drops history, the resulting snapshot is marked incomplete instead of presenting +partial totals as complete. `signals.json` contributes model/session metadata only +when no completed turns are available and is also limited to 1 MiB. Those local daily token buckets also feed the shared Usage & Spend catalog so an enabled Grok subscription is counted instead of omitted. SuperGrok/X Premium+ credits remain a quota window on the usage bar; they are never converted into -dollars. Local session scans run on the dedicated background usage-scan queue; -menu cards and spend views reuse the already-published snapshot instead of -walking the session directory whenever they render. +dollars. Dollar figures come from the spend the CLI recorded, or from public xAI list +prices where it recorded none; neither is a bill. +Local session scans run on the dedicated background usage-scan queue; menu cards +and spend views reuse the already-published snapshot instead of walking the session +directory whenever they render. ## Menu bar appearance