diff --git a/CHANGELOG.md b/CHANGELOG.md index 7366c7dc12..b1e9e2be38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Fixed the native blue selection highlight reappearing on provider cards after cached provider switches: cross-class cached rows now replace the item shell so the highlight override survives (#2998, #3091, #3093). Thanks @kiranmagic7! - Menu bar conditionals can now test every comparable block, not just usage percentages: time to reset, run-out estimate, pace, credit balance, today/30-day cost, and the direct primary/secondary/tertiary lanes, with a used/remaining select wherever both readings exist (so "session > 50% used **and** session resets in < 2h" or "balance remaining >= 5" are expressible). Ships an "Auto % / Resets in" default that shows the percentage while the lane has headroom and the reset countdown once it is spent (#3076). - Fixed Codex and Grok app crashes when an RPC timeout or child exit races a write to closed subprocess stdin (#3087). +- Publish live xAI Management API daily spend and local Grok session tokens into the shared Usage & Spend catalog. Prepaid Grok credits stay a quota (never converted to dollars); xAI prepaid balance stays remaining credit, not spend (#3085). - Added conditional tokens to the menu bar layout editor: named, reusable if/then/else rules (1–4 AND/OR clauses over Session/Weekly/Scoped/Auto thresholds) that swap or hide tokens based on live usage, downgrade-safe and localized across all 23 catalogs (#3076). Thanks @wdmitchelluk! - Fixed inconsistent German localization of "About" ("Um" → "Über") (#3077). Thanks @dwt! - Localized provider usage details in Simplified Chinese: DeepSeek detailed usage/balance, z.ai/GLM quota details, token charts, and the 5-hour reset text (#3084). Thanks @haixing23! diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index c6f454e665..c534bae5cd 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -270,6 +270,23 @@ enum SpendDashboardSource { var unavailableSourceIDs: Set = [] var confirmedEmptySourceIDs: Set = [] for provider in providers where provider != .codex { + // 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) + { + inputs.append(SpendDashboardModel.ProviderInput( + provider: .grok, + displayName: store.metadata(for: .grok).displayName, + snapshot: snapshot)) + } else { + confirmedEmptySourceIDs.insert(UsageProvider.grok.rawValue) + } + continue + } guard let baseline = providerBaselines.first(where: { $0.provider == provider }) else { unavailableSourceIDs.insert(provider.rawValue) continue @@ -723,6 +740,14 @@ 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. + if provider == .grok { + return store.tokenSnapshot( + fromProviderSnapshot: store.snapshot(for: .grok), + provider: .grok, + historyDays: self.scanDays) + } if UsageStore.tokenCostRequiresProviderSnapshot(provider), let usage = store.snapshot(for: provider.instanceID), let derived = store.tokenSnapshot( diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index b06a2b24da..5a461a67df 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -1067,9 +1067,9 @@ struct SpendDashboardModel: Equatable, Sendable { } private static func bucketCalendar(for provider: UsageProvider, displayCalendar: Calendar) -> Calendar { - guard provider == .mistral || provider == .openrouter else { return displayCalendar } - // Mistral and OpenRouter label daily buckets and snapshot coverage by UTC day. Map each UTC boundary into the - // containing local dashboard day instead of reinterpreting the label as a local date. + guard provider == .mistral || provider == .openrouter || provider == .xai else { return displayCalendar } + // Mistral, OpenRouter, and xAI label daily buckets and snapshot coverage by UTC day. Map each UTC boundary into + // the containing local dashboard day instead of reinterpreting the label as a local date. return self.gregorianCalendar(timeZone: TimeZone(secondsFromGMT: 0) ?? .gmt) } diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index 283808a539..d838dbe594 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -746,6 +746,11 @@ extension UsageStore { self.publishTokenSnapshot(tokenSnapshot, for: provider) self.tokenErrors[provider.instanceID] = nil self.tokenFailureGates[provider.instanceID]?.recordSuccess() + } else if provider == .xai, XAICostUsageMapping.isAnalyticsUnavailable(backfilled) { + // Provider-specific by design: prepaid balance without usage history is unavailable, + // not a confirmed-empty $0 spend row. + self.clearTokenSnapshot(for: provider) + self.tokenErrors[provider.instanceID] = nil } else if Self.tokenCostRequiresProviderSnapshot(provider) { self.publishConfirmedEmptyTokenSnapshot(for: provider) self.tokenErrors[provider.instanceID] = nil @@ -1440,7 +1445,19 @@ extension UsageStore { self.errors[provider.instanceID] = error.localizedDescription if !preservesPriorData, !preservesClaudeWebSessionFailure { self.snapshots.removeValue(forKey: provider.instanceID) - if Self.tokenCostRequiresProviderSnapshot(provider) { + // Provider-specific by design: local ~/.grok/sessions tokens remain readable + // when the remote billing probe fails. + if provider == .grok { + if let local = self.tokenSnapshot( + fromProviderSnapshot: nil, + provider: .grok, + historyDays: SpendDashboardSource.scanDays) + { + self.publishTokenSnapshot(local, for: provider) + } else { + self.clearTokenSnapshot(for: provider) + } + } else if Self.tokenCostRequiresProviderSnapshot(provider) { self.clearTokenSnapshot(for: provider) } } diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index eb2864b089..a52c7ddb14 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -174,6 +174,11 @@ extension UsageStore { guard Self.tokenCostRequiresProviderSnapshot(provider) else { return } if let tokenSnapshot = self.tokenSnapshot(fromProviderSnapshot: snapshot, provider: provider) { self.publishTokenSnapshot(tokenSnapshot, for: provider) + // Provider-specific by design: a prepaid-balance snapshot without a usage chart means + // analytics failed. Leave the source unpublished so Overview counts it unavailable + // instead of known-zero spend. + } else if provider == .xai, XAICostUsageMapping.isAnalyticsUnavailable(snapshot) { + self.clearTokenSnapshot(for: provider) } else { self.publishConfirmedEmptyTokenSnapshot(for: provider) } @@ -449,6 +454,9 @@ extension UsageStore { -> CostUsageTokenSnapshot? { 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. switch provider { case .openai: return snapshot?.openAIAPIUsage?.toCostUsageTokenSnapshot() @@ -464,14 +472,21 @@ extension UsageStore { } case .openrouter: return snapshot?.costUsage + case .xai: + return snapshot.flatMap { XAICostUsageMapping.tokenSnapshot(from: $0, historyDays: windowDays) } + case .grok: + return GrokLocalSessionScanner.summarize(lookbackDays: windowDays) + .toCostUsageTokenSnapshot(historyDays: windowDays) default: return nil } } 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. switch provider { - case .mistral, .openai, .opencodego, .openrouter: + case .grok, .mistral, .openai, .opencodego, .openrouter, .xai: true default: false diff --git a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift index c5b1afded7..7270b40298 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift @@ -1,5 +1,20 @@ 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 sessionCount: Int + public let models: [String] + + public init(date: String, totalTokens: Int, sessionCount: Int, models: [String]) { + self.date = date + self.totalTokens = totalTokens + self.sessionCount = sessionCount + self.models = models + } +} + /// Aggregated stats from local `~/.grok/sessions/**/signals.json` files. /// Used as a local fallback view when the JSON-RPC billing call is unavailable. public struct GrokLocalSessionSummary: Sendable { @@ -8,19 +23,53 @@ public struct GrokLocalSessionSummary: Sendable { public let lastSessionAt: Date? public let primaryModel: String? public let models: [String] + public let daily: [GrokLocalDailyBucket] + public let scannedAt: Date public init( sessionCount: Int, totalTokens: Int, lastSessionAt: Date?, primaryModel: String?, - models: [String]) + models: [String], + daily: [GrokLocalDailyBucket] = [], + scannedAt: Date = .init()) { self.sessionCount = sessionCount self.totalTokens = totalTokens self.lastSessionAt = lastSessionAt self.primaryModel = primaryModel self.models = models + self.daily = daily + self.scannedAt = scannedAt + } + + /// Local session tokens only. SuperGrok credits are a quota, not dollars, so this never invents spend. + public func toCostUsageTokenSnapshot(historyDays: Int) -> CostUsageTokenSnapshot? { + let entries = self.daily.map { bucket in + CostUsageDailyReport.Entry( + date: bucket.date, + inputTokens: nil, + outputTokens: nil, + totalTokens: bucket.totalTokens, + requestCount: bucket.sessionCount, + costUSD: nil, + modelsUsed: bucket.models.isEmpty ? nil : bucket.models, + modelBreakdowns: 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 } + return CostUsageTokenSnapshot( + sessionTokens: todayTokens, + sessionCostUSD: nil, + last30DaysTokens: self.totalTokens, + last30DaysCostUSD: nil, + historyDays: historyDays, + historyCoverageIsEstablished: true, + costProvenance: .unknown, + daily: entries, + updatedAt: self.scannedAt) } } @@ -46,14 +95,19 @@ public enum GrokLocalSessionScanner { totalTokens: 0, lastSessionAt: nil, primaryModel: nil, - models: []) + models: [], + scannedAt: now) } - let lookbackCutoff = Calendar.current.date(byAdding: .day, value: -lookbackDays, to: now) ?? now + let calendar = Calendar.current + let lookbackCutoff = calendar.date(byAdding: .day, value: -lookbackDays, to: now) ?? now var sessionCount = 0 var totalTokens = 0 var lastSessionAt: Date? var modelCounts: [String: Int] = [:] + var dailyTokens: [String: Int] = [:] + var dailySessions: [String: Int] = [:] + var dailyModels: [String: [String: Int]] = [:] while let url = rootEnum.nextObject() as? URL { guard url.lastPathComponent == "signals.json" else { continue } @@ -68,33 +122,63 @@ public enum GrokLocalSessionScanner { sessionCount += 1 let beforeCompaction = (json["totalTokensBeforeCompaction"] as? Int) ?? 0 let contextUsed = (json["contextTokensUsed"] as? Int) ?? 0 - totalTokens += beforeCompaction + contextUsed + let sessionTokens = beforeCompaction + contextUsed + totalTokens += sessionTokens if mtime > (lastSessionAt ?? Date.distantPast) { lastSessionAt = mtime } + var sessionModels: [String] = [] if let primary = (json["primaryModelId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), !primary.isEmpty { 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) } } } + + 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 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) + } return GrokLocalSessionSummary( sessionCount: sessionCount, totalTokens: totalTokens, lastSessionAt: lastSessionAt, primaryModel: sortedModels.first, - models: sortedModels) + models: sortedModels, + daily: daily, + scannedAt: now) + } + + 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 { + return nil + } + return String(format: "%04d-%02d-%02d", year, month, day) } } diff --git a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift index 7596107917..dd3b05020d 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift @@ -85,8 +85,11 @@ public enum GrokProviderDescriptor { ProviderColor(hex: 0xFDFDFD), ]), tokenCost: ProviderTokenCostConfig( - supportsTokenCost: false, - noDataMessage: { "Grok cost summary is not supported yet." }), + supportsTokenCost: true, + noDataMessage: { + "Grok token totals come from local ~/.grok/sessions logs. " + + "Subscription credits are not converted to dollars." + }), pace: ProviderPaceCapability( resetWindowPace: .custom { window, now in guard Self.primaryLabel(window: window, now: now) == "Weekly", diff --git a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift index 00542b2357..a326a21976 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift @@ -66,6 +66,8 @@ public struct GrokUsageSnapshot: Sendable { primary: primary, secondary: nil, tertiary: nil, + costUsage: self.localSummary?.toCostUsageTokenSnapshot( + historyDays: GrokLocalSessionScanner.defaultLookbackDays), updatedAt: self.updatedAt, identity: identity) } diff --git a/Sources/CodexBarCore/Providers/XAI/XAICostUsageMapping.swift b/Sources/CodexBarCore/Providers/XAI/XAICostUsageMapping.swift new file mode 100644 index 0000000000..dbea99b8a3 --- /dev/null +++ b/Sources/CodexBarCore/Providers/XAI/XAICostUsageMapping.swift @@ -0,0 +1,59 @@ +import Foundation + +public enum XAICostUsageMapping { + public static let historyDays = 30 + private static let dayPattern = #"^\d{4}-\d{2}-\d{2}$"# + + /// True when prepaid balance arrived but `/usage` history did not. + public static func isAnalyticsUnavailable(_ snapshot: UsageSnapshot) -> Bool { + !snapshot.details.contains { $0.chart != nil } + } + + /// Maps the Management API daily-spend chart onto the shared spend catalog. + /// Prepaid ledger balance is remaining credit, not spend, so it is never used here. + public static func tokenSnapshot(from snapshot: UsageSnapshot, historyDays _: Int) -> CostUsageTokenSnapshot? { + guard let chart = snapshot.details.compactMap(\.chart).first else { return nil } + let entries = chart.points + .compactMap { point -> CostUsageDailyReport.Entry? in + guard point.label.range(of: self.dayPattern, options: .regularExpression) != nil, + point.value.isFinite, + point.value >= 0 + else { return nil } + return CostUsageDailyReport.Entry( + date: point.label, + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + costUSD: point.value, + modelsUsed: nil, + modelBreakdowns: nil) + } + .sorted { $0.date < $1.date } + let total = entries.compactMap(\.costUSD).reduce(0, +) + guard total.isFinite else { return nil } + let today = self.utcDayKey(snapshot.updatedAt) + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: entries.first { $0.date == today }?.costUSD, + last30DaysTokens: nil, + last30DaysCostUSD: total, + historyDays: Self.historyDays, + historyCoverageIsEstablished: snapshot.dataConfidence != .estimated, + historyLabel: snapshot.dataConfidence == .estimated ? "Last 30 days (partial)" : nil, + meteredCostUSD: total, + costProvenance: .vendorMetered, + daily: entries, + updatedAt: snapshot.updatedAt) + } + + static func utcDayKey(_ date: Date) -> String { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt + let components = calendar.dateComponents([.year, .month, .day], from: date) + return String( + format: "%04d-%02d-%02d", + components.year ?? 0, + components.month ?? 0, + components.day ?? 0) + } +} diff --git a/Sources/CodexBarCore/Providers/XAI/XAIProviderDescriptor.swift b/Sources/CodexBarCore/Providers/XAI/XAIProviderDescriptor.swift index d01ae70a65..25f361f005 100644 --- a/Sources/CodexBarCore/Providers/XAI/XAIProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/XAI/XAIProviderDescriptor.swift @@ -39,8 +39,11 @@ public enum XAIProviderDescriptor { ProviderColor(hex: 0xF5F5F7), ]), tokenCost: ProviderTokenCostConfig( - supportsTokenCost: false, - noDataMessage: { "xAI spend history comes from the Management API billing endpoints." }), + supportsTokenCost: true, + noDataMessage: { + "xAI daily spend requires a Management API key and team ID. " + + "Prepaid balance is not treated as spend." + }), presentation: ProviderUsagePresentation( identityPresenter: { provider, snapshot in guard let plan = snapshot.loginMethod(for: provider), !plan.isEmpty else { diff --git a/Sources/CodexBarCore/Resources/Plugins/xai.js b/Sources/CodexBarCore/Resources/Plugins/xai.js index 5ad9db1cf9..4e1f62f849 100644 --- a/Sources/CodexBarCore/Resources/Plugins/xai.js +++ b/Sources/CodexBarCore/Resources/Plugins/xai.js @@ -43,6 +43,7 @@ defineProvider({ `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")} ${String(date.getUTCHours()).padStart(2, "0")}:${String(date.getUTCMinutes()).padStart(2, "0")}:${String(date.getUTCSeconds()).padStart(2, "0")}`; let daily = []; let partial = false; + let historyAvailable = false; try { const usage = await ctx.http.postJSON(`${root}/usage`, { body: { @@ -61,21 +62,29 @@ defineProvider({ ); } if (usage.status >= 200 && usage.status < 300) { + if (!usage.json || !Array.isArray(usage.json.timeSeries)) { + throw new Error("invalid xAI usage history"); + } const totals = {}; - for (const series of usage.json.timeSeries || []) - for (const point of series.dataPoints || []) { + for (const series of usage.json.timeSeries) { + if (!series || !Array.isArray(series.dataPoints)) { + throw new Error("invalid xAI usage history"); + } + for (const point of series.dataPoints) { const date = new Date(point.timestamp); - const value = (point.values || [0])[0] || 0; - if (!Number.isFinite(date.getTime()) || typeof value !== "number" || !Number.isFinite(value)) { + const value = Array.isArray(point.values) ? point.values[0] : undefined; + if (!Number.isFinite(date.getTime()) || typeof value !== "number" || !Number.isFinite(value) || value < 0) { throw new Error("invalid xAI usage history"); } const day = date.toISOString().slice(0, 10); totals[day] = (totals[day] || 0) + value; } + } daily = Object.keys(totals) .sort() .map((day) => ({ label: day, value: totals[day] })); partial = usage.json.limitReached === true; + historyAvailable = true; } } catch (error) { if (/rejected the Management API key/.test(error.message)) throw error; @@ -94,7 +103,9 @@ defineProvider({ value: `$${daily.reduce((sum, point) => sum + point.value, 0).toFixed(2)}`, }, ], - chart: daily.length ? { kind: "bars", title: "Daily spend", unit: "USD", points: daily } : undefined, + // Emit an empty chart on successful history so spend mapping can tell + // "zero days" from "analytics unavailable". + chart: historyAvailable ? { kind: "bars", title: "Daily spend", unit: "USD", points: daily } : undefined, }, ], }; diff --git a/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift b/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift new file mode 100644 index 0000000000..94f09a72d6 --- /dev/null +++ b/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift @@ -0,0 +1,122 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct GrokLocalSessionScannerTests { + @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) + + 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 = 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.daily.map(\.sessionCount) == [1, 1]) + #expect(Set(summary.daily.map(\.date)).count == 2) + + 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 `idle days do not reuse yesterday as today`() 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)) + try self.writeSignals( + at: session.appendingPathComponent("signals.json"), + tokens: 100, + model: "grok-4.6", + date: yesterday) + let summary = GrokLocalSessionScanner.summarize( + env: ["GROK_HOME": root.path], + lookbackDays: 7, + now: today) + let snapshot = try #require(summary.toCostUsageTokenSnapshot(historyDays: 7)) + #expect(snapshot.last30DaysTokens == 100) + #expect(snapshot.sessionTokens == nil) + } + + @Test + func `empty homes do not publish a spend snapshot`() { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("grok-session-empty-\(UUID().uuidString)", isDirectory: true) + let summary = GrokLocalSessionScanner.summarize( + env: ["GROK_HOME": root.path], + lookbackDays: 7, + now: Date()) + #expect(summary.toCostUsageTokenSnapshot(historyDays: 7) == nil) + } + + @Test + func `local scan clock wins over a stale remote snapshot`() throws { + let calendar = Calendar.current + let staleRemoteTime = Date(timeIntervalSince1970: 1_787_079_600) + let 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"], + daily: [GrokLocalDailyBucket( + date: localDay, + totalTokens: 250, + sessionCount: 1, + models: ["grok-4.6"])], + scannedAt: localScanTime) + let remote = GrokUsageSnapshot( + billing: nil, + credentials: nil, + localSummary: summary, + cliVersion: nil, + updatedAt: staleRemoteTime) + + let snapshot = try #require(remote.toUsageSnapshot().costUsage) + #expect(snapshot.sessionTokens == 250) + #expect(snapshot.updatedAt == localScanTime) + } + + private func writeSignals(at url: URL, tokens: Int, model: String, date: Date) throws { + let payload: [String: Any] = [ + "contextTokensUsed": tokens, + "totalTokensBeforeCompaction": 0, + "primaryModelId": model, + "modelsUsed": [model], + ] + try JSONSerialization.data(withJSONObject: payload).write(to: url) + try FileManager.default.setAttributes([.modificationDate: date], ofItemAtPath: url.path) + } +} diff --git a/Tests/CodexBarTests/GrokXAISpendCatalogTests.swift b/Tests/CodexBarTests/GrokXAISpendCatalogTests.swift new file mode 100644 index 0000000000..c58a1a4f09 --- /dev/null +++ b/Tests/CodexBarTests/GrokXAISpendCatalogTests.swift @@ -0,0 +1,37 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +struct GrokXAISpendCatalogTests { + @Test + 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) + #expect(ProviderDescriptorRegistry.descriptor(for: .xai).tokenCost.supportsTokenCost) + } + + @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 catalog proof`() throws { + let summary = GrokLocalSessionScanner.summarize(lookbackDays: SpendDashboardSource.scanDays) + let snapshot = try #require(summary.toCostUsageTokenSnapshot(historyDays: SpendDashboardSource.scanDays)) + let model = SpendDashboardModel.build( + inputs: [.init(provider: .grok, displayName: "Grok", snapshot: snapshot)], + requestedDays: 30, + now: summary.scannedAt) + let grokRow = try #require(model.groups.flatMap(\.providers).first { $0.id == UsageProvider.grok.rawValue }) + + #expect(model.availableSources.map(\.id) == [UsageProvider.grok.rawValue]) + #expect(grokRow.totalTokens == snapshot.last30DaysTokens) + #expect(model.tokenActivity.contains { $0.totalTokens != nil }) + + print("catalog_source=grok") + print("today_tokens=\(snapshot.sessionTokens ?? 0)") + print("last_30_days_tokens=\(grokRow.totalTokens ?? 0)") + print("daily_buckets=\(snapshot.daily.count)") + print("available_sources=\(model.availableSources.map(\.id).joined(separator: ","))") + } +} diff --git a/Tests/CodexBarTests/PopupLocalizationTests.swift b/Tests/CodexBarTests/PopupLocalizationTests.swift index 5840738362..ac703a4925 100644 --- a/Tests/CodexBarTests/PopupLocalizationTests.swift +++ b/Tests/CodexBarTests/PopupLocalizationTests.swift @@ -127,6 +127,8 @@ struct PopupLocalizationTests { now: now)) #expect(model.metrics.first?.title == "額度") + // After 84a4ca725, generic providers localize section titles and row labels via L(); + // values and chart point labels stay canonical. let apiKey = try #require(model.providerDetails.first { $0.title == "API 金鑰" }) #expect(apiKey.rows.map(\.label) == [ "API key budget", "API key remaining", "API key used", "Reset window", diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index f97f4e58e0..4c085be86b 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -896,61 +896,61 @@ struct ProviderArchitectureGatekeeperTests { reason: "This observation touchpoint reads a fixed provider field so UI invalidation tracks that setting."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 385, + line: 402, 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: 387, + line: 404, 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: 459, + line: 476, 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: 461, + line: 478, 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: 512, + line: 529, 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: 555, + line: 572, 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: 606, + line: 623, anchor: "if providers.contains(.codex) {", expectedProviderIDs: ["codex"], reason: "This ownership projection includes the fixed Codex account roster without performing menu-time IO."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 1450, + line: 1475, 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: 1479, + line: 1504, 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: 1496, + line: 1521, anchor: "if sourceID.hasPrefix(\"codex:\") { return .codex }", expectedProviderIDs: ["codex"], reason: "This publication projection maps stable Codex account source IDs back to their provider family."), @@ -1082,25 +1082,25 @@ 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: 927, + line: 932, anchor: "let snapshotEmail = CodexIdentityResolver.normalizeEmail(snapshot.accountEmail(for: .codex)),", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 935, + line: 940, anchor: "let identity = snapshot.identity(for: .codex)", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 937, + line: 942, anchor: "providerID: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 1474, + line: 1491, 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."), @@ -1215,61 +1215,61 @@ struct ProviderArchitectureGatekeeperTests { "double-counting the same logs."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 212, + line: 217, anchor: "let scope = self.tokenCostScope(for: .codex)", expectedProviderIDs: ["codex"], reason: "The Codex-only cache hydration path passes its fixed provider identity to shared state helpers."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 214, + line: 219, anchor: "let publicationRevision = self.providerPublicationRevision(for: .codex)", expectedProviderIDs: ["codex"], reason: "The Codex-only cache hydration path passes its fixed provider identity to shared state helpers."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 215, + line: 220, anchor: "let providerConfigRevision = self.settings.providerConfigRevision(for: .codex)", expectedProviderIDs: ["codex"], reason: "The Codex-only cache hydration path passes its fixed provider identity to shared state helpers."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 217, + line: 222, anchor: "let tokenSnapshotScopeSignature = self.tokenSnapshotScopeSignature(for: .codex)", expectedProviderIDs: ["codex"], reason: "The Codex-only cache hydration path passes its fixed provider identity to shared state helpers."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 218, + line: 223, anchor: "let tokenSnapshotPublicationRevision = self.tokenSnapshotPublicationRevision(for: .codex)", expectedProviderIDs: ["codex"], reason: "The Codex-only cache hydration path passes its fixed provider identity to shared state helpers."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 248, + line: 253, anchor: "self.settings.isCostUsageEffectivelyEnabled(for: .codex),", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 249, + line: 254, anchor: "self.isEnabled(.codex),", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 258, + line: 263, anchor: "self.installCachedTokenSnapshot(result.snapshot, for: .codex)", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 358, + line: 363, anchor: "let credentialFingerprint = CookieHeaderCache.loadForDisplay(provider: .cursor)", expectedProviderIDs: ["cursor"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 373, + line: 378, anchor: "let scope = self.tokenCostScope(for: .cursor)", expectedProviderIDs: ["cursor"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), @@ -2310,7 +2310,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 544, + line: 561, anchor: "(providers.contains(.codex) && settings.codexLocalSessionCostLedgerEnabled)", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2344,13 +2344,13 @@ struct ProviderArchitectureGatekeeperTests { path: "Sources/CodexBar/SpendDashboardController.swift", line: 272, anchor: "for provider in providers where provider != .codex {", - expectedProviderIDs: ["codex"], - expectedReferenceCount: 1, - expectedReferenceFingerprint: ["codex@0"], + expectedProviderIDs: ["codex", "grok"], + expectedReferenceCount: 7, + expectedReferenceFingerprint: ["codex@0", "grok@3", "grok@5", "grok@6", "grok@10", "grok@11", "grok@14"], reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 634, + line: 651, anchor: "if providers.contains(.codex) {", expectedProviderIDs: ["codex"], expectedReferenceCount: 3, @@ -2358,7 +2358,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 699, + line: 716, anchor: "guard provider != .codex else { return nil }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2366,7 +2366,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: 1522, + line: 1547, anchor: "guard input.provider == .codex,", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2383,10 +2383,10 @@ struct ProviderArchitectureGatekeeperTests { AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardModel.swift", line: 1070, - anchor: "guard provider == .mistral || provider == .openrouter else { return displayCalendar }", - expectedProviderIDs: ["mistral", "openrouter"], - expectedReferenceCount: 2, - expectedReferenceFingerprint: ["mistral@0", "openrouter@0"], + anchor: "guard provider == .mistral || provider == .openrouter || provider == .xai else { return displayCalendar }", + expectedProviderIDs: ["mistral", "openrouter", "xai"], + expectedReferenceCount: 3, + expectedReferenceFingerprint: ["mistral@0", "openrouter@0", "xai@0"], reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController+AccountMenuDisplay.swift", @@ -2840,13 +2840,13 @@ struct ProviderArchitectureGatekeeperTests { path: "Sources/CodexBar/UsageStore+Refresh.swift", line: 731, anchor: "codexOwnerKey: provider == .codex ? context.codexSessionQuotaOwnerKey : nil)", - expectedProviderIDs: ["claude", "codex", "deepseek"], - expectedReferenceCount: 4, - expectedReferenceFingerprint: ["codex@0", "claude@4", "codex@5", "deepseek@11"], + expectedProviderIDs: ["claude", "codex", "deepseek", "xai"], + expectedReferenceCount: 5, + expectedReferenceFingerprint: ["codex@0", "claude@4", "codex@5", "deepseek@11", "xai@18"], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 763, + line: 768, anchor: "if provider == .gemini {", expectedProviderIDs: ["gemini"], expectedReferenceCount: 1, @@ -2854,7 +2854,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: 778, + line: 783, anchor: "let isClaudeOAuthSample = provider == .claude && result.strategyKind == .oauth", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -2862,7 +2862,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: 811, + line: 816, anchor: "if provider == .codex {", expectedProviderIDs: ["codex", "deepseek"], expectedReferenceCount: 4, @@ -2870,7 +2870,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: 860, + line: 865, anchor: "guard provider == .deepseek else { return snapshot }", expectedProviderIDs: ["codex", "deepseek"], expectedReferenceCount: 8, @@ -2887,7 +2887,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: 987, + line: 992, anchor: "guard provider == .claude, !hasSelectedTokenAccount else { return false }", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -2895,7 +2895,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: 1332, + line: 1337, anchor: "if provider == .gemini, Self.isGeminiConsumerTierDeprecationError(error) {", expectedProviderIDs: ["claude", "gemini"], expectedReferenceCount: 2, @@ -2903,7 +2903,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: 1376, + line: 1381, anchor: "if provider == .claude,", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -2911,7 +2911,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: 1392, + line: 1397, anchor: "if provider == .claude,", expectedProviderIDs: ["claude"], expectedReferenceCount: 5, @@ -2919,7 +2919,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: 1475, + line: 1492, anchor: "cached.cacheKey == self.tokenAccountSnapshotCacheKey(provider: .claude, account: currentAccount)", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3081,7 +3081,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 221, + line: 226, anchor: "guard self.tokenSnapshotPublicationForCurrentProviderConfig(for: .codex) == nil else { return }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3089,7 +3089,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 245, + line: 250, anchor: "guard self.providerPublicationRevisionIsCurrent(publicationRevision, for: .codex),", expectedProviderIDs: ["codex"], expectedReferenceCount: 9, @@ -3107,7 +3107,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 282, + line: 287, anchor: "return provider == .codex && self.codexCostCatchUpActivity?.phase == .indexing", expectedProviderIDs: ["claude", "codex", "vertexai"], expectedReferenceCount: 4, @@ -3115,7 +3115,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 347, + line: 352, anchor: "guard provider == .cursor else {", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3123,7 +3123,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 413, + line: 418, anchor: "if provider == .cursor,", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3131,7 +3131,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 434, + line: 439, anchor: "guard provider == .cursor,", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3139,24 +3139,28 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 453, + line: 461, anchor: "case .openai:", - expectedProviderIDs: ["mistral", "openai", "opencodego", "openrouter"], - expectedReferenceCount: 8, + expectedProviderIDs: ["grok", "mistral", "openai", "opencodego", "openrouter", "xai"], + expectedReferenceCount: 12, expectedReferenceFingerprint: [ "openai@0", "mistral@2", "opencodego@4", "openrouter@12", - "mistral@21", - "openai@21", - "opencodego@21", - "openrouter@21", + "xai@14", + "grok@16", + "grok@28", + "mistral@28", + "openai@28", + "opencodego@28", + "openrouter@28", + "xai@28", ], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 516, + line: 531, anchor: "self.tokenFailureGates[.codex]?.reset()", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 2, diff --git a/Tests/CodexBarTests/SpendDashboardModelTests.swift b/Tests/CodexBarTests/SpendDashboardModelTests.swift index 2fc04609e3..6056331e6f 100644 --- a/Tests/CodexBarTests/SpendDashboardModelTests.swift +++ b/Tests/CodexBarTests/SpendDashboardModelTests.swift @@ -77,8 +77,10 @@ struct SpendDashboardModelTests { .mistral, .bedrock, .cursor, + .grok, .opencodego, .openrouter, + .xai, ]) } diff --git a/Tests/CodexBarTests/XAICostUsageMappingTests.swift b/Tests/CodexBarTests/XAICostUsageMappingTests.swift new file mode 100644 index 0000000000..d4185b0f92 --- /dev/null +++ b/Tests/CodexBarTests/XAICostUsageMappingTests.swift @@ -0,0 +1,98 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct XAICostUsageMappingTests { + @Test + func `daily spend chart becomes vendor-metered catalog input`() throws { + let snapshot = try self.snapshot( + points: [("2026-08-17", 0.50), ("2026-08-18", 1.26)], + confidence: .exact) + let mapped = try #require(XAICostUsageMapping.tokenSnapshot(from: snapshot, historyDays: 30)) + #expect(mapped.last30DaysCostUSD == 1.76) + #expect(mapped.last30DaysTokens == nil) + #expect(mapped.sessionCostUSD == nil) + #expect(mapped.historyDays == 30) + #expect(mapped.costProvenance == .vendorMetered) + #expect(mapped.daily.map(\.date) == ["2026-08-17", "2026-08-18"]) + #expect(mapped.daily.map(\.costUSD) == [0.50, 1.26]) + #expect(mapped.historyCoverageIsEstablished) + } + + @Test + func `today is the UTC day of updatedAt not the newest point`() throws { + let snapshot = try self.snapshot( + points: [("2027-01-14", 0.50), ("2027-01-15", 1.26)], + confidence: .exact, + updatedAt: Date(timeIntervalSince1970: 1_800_000_000)) + let mapped = try #require(XAICostUsageMapping.tokenSnapshot(from: snapshot, historyDays: 365)) + #expect(mapped.sessionCostUSD == 1.26) + #expect(mapped.historyDays == 30) + } + + @Test + func `requested 365-day window stays a 30-day source`() throws { + let snapshot = try self.snapshot( + points: [("2026-08-18", 1.0)], + confidence: .exact) + let mapped = try #require(XAICostUsageMapping.tokenSnapshot(from: snapshot, historyDays: 365)) + #expect(mapped.historyDays == 30) + #expect(mapped.last30DaysCostUSD == 1.0) + } + + @Test + func `prepaid balance alone is not spend`() { + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 10, + limit: 0, + currencyCode: "USD", + period: "Prepaid credits", + updatedAt: Date()), + updatedAt: Date()) + #expect(XAICostUsageMapping.tokenSnapshot(from: snapshot, historyDays: 30) == nil) + #expect(XAICostUsageMapping.isAnalyticsUnavailable(snapshot)) + } + + @Test + func `empty successful chart is confirmed empty not unavailable`() throws { + let snapshot = try self.snapshot(points: [], confidence: .exact) + #expect(XAICostUsageMapping.isAnalyticsUnavailable(snapshot) == false) + let mapped = try #require(XAICostUsageMapping.tokenSnapshot(from: snapshot, historyDays: 30)) + #expect(mapped.daily.isEmpty) + #expect(mapped.last30DaysCostUSD == 0) + #expect(mapped.sessionCostUSD == nil) + } + + @Test + func `partial history stays estimated`() throws { + let snapshot = try self.snapshot(points: [("2026-08-18", 1.0)], confidence: .estimated) + let mapped = try #require(XAICostUsageMapping.tokenSnapshot(from: snapshot, historyDays: 30)) + #expect(mapped.historyCoverageIsEstablished == false) + #expect(mapped.historyLabel == "Last 30 days (partial)") + } + + private func snapshot( + points: [(String, Double)], + confidence: UsageDataConfidence, + updatedAt: Date = Date(timeIntervalSince1970: 1_800_000_000)) throws -> UsageSnapshot + { + let chart = try ProviderDetailSection.Chart( + kind: .bars, + title: "Daily spend", + unit: "USD", + points: points.map { try ProviderDetailSection.Chart.Point(label: $0.0, value: $0.1) }) + let details = try [ProviderDetailSection( + title: "Billing summary", + rows: [ProviderDetailSection.Row(label: "Last 30 days", value: "$1.76")], + chart: chart)] + return UsageSnapshot( + primary: nil, + secondary: nil, + details: details, + updatedAt: updatedAt, + dataConfidence: confidence) + } +} diff --git a/Tests/CodexBarTests/XAIProviderTests.swift b/Tests/CodexBarTests/XAIProviderTests.swift index 7135872b0f..44a9d38b8d 100644 --- a/Tests/CodexBarTests/XAIProviderTests.swift +++ b/Tests/CodexBarTests/XAIProviderTests.swift @@ -134,6 +134,8 @@ struct XAIProviderTests { #expect(degraded.providerCost?.used == 10) #expect(degraded.details.first?.chart == nil) #expect(degraded.dataConfidence == .exact) + #expect(XAICostUsageMapping.isAnalyticsUnavailable(degraded)) + #expect(XAICostUsageMapping.tokenSnapshot(from: degraded, historyDays: 30) == nil) let partial = try await Self.fetch(usageBody: Self.usageFixture.replacingOccurrences( of: #""limitReached": false"#, @@ -142,6 +144,30 @@ struct XAIProviderTests { #expect(partial.dataConfidence == .estimated) } + @Test + func `successful empty usage history emits an empty chart`() async throws { + let snapshot = try await Self.fetch(usageBody: #"{"timeSeries":[],"limitReached":false}"#) + #expect(snapshot.details.first?.chart?.points.isEmpty == true) + #expect(XAICostUsageMapping.isAnalyticsUnavailable(snapshot) == false) + let mapped = try #require(XAICostUsageMapping.tokenSnapshot(from: snapshot, historyDays: 365)) + #expect(mapped.daily.isEmpty) + #expect(mapped.last30DaysCostUSD == 0) + #expect(mapped.historyDays == 30) + } + + @Test(arguments: [ + #"{}"#, + #"{"timeSeries":null,"limitReached":false}"#, + #"{"timeSeries":[{}],"limitReached":false}"#, + #"{"timeSeries":[{"dataPoints":[{"timestamp":"2027-01-15T00:00:00Z"}]}],"limitReached":false}"#, + ]) + func `malformed successful usage history stays unavailable`(body: String) async throws { + let snapshot = try await Self.fetch(usageBody: body) + #expect(snapshot.details.first?.chart == nil) + #expect(XAICostUsageMapping.isAnalyticsUnavailable(snapshot)) + #expect(XAICostUsageMapping.tokenSnapshot(from: snapshot, historyDays: 30) == nil) + } + @Test @MainActor func `descriptor registry menu card and CLI retain xAI presentation`() async throws { let descriptor = ProviderDescriptorRegistry.descriptor(for: .xai) diff --git a/docs/grok.md b/docs/grok.md index f26f710705..348d1c8186 100644 --- a/docs/grok.md +++ b/docs/grok.md @@ -197,8 +197,13 @@ Each session directory contains `signals.json` with fields like: ``` CodexBar aggregates these into a `GrokLocalSessionSummary` (session count, total -tokens, last session time, primary model) and exposes it for diagnostics even when -the RPC path is unavailable. +tokens, last session time, primary model, per-day token buckets) and exposes it for +diagnostics even when the RPC path is unavailable. + +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. ## Status diff --git a/docs/xai.md b/docs/xai.md index 84b85696cb..3c2ec8f0c1 100644 --- a/docs/xai.md +++ b/docs/xai.md @@ -69,6 +69,9 @@ spend with today/30-day totals. When xAI reports its analytics cardinality cap ( "Last 30 days (partial)" and the snapshot is marked estimated instead of exact. Prepaid money is not a quota, so no session or weekly meters are synthesized. +The same daily spend series is published into Settings → Usage & Spend and Overview as vendor-metered USD. The prepaid +ledger balance is remaining credit and is never treated as spend. + ## CLI Usage ```bash