Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
25 changes: 25 additions & 0 deletions Sources/CodexBar/SpendDashboardController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,23 @@ enum SpendDashboardSource {
var unavailableSourceIDs: Set<String> = []
var confirmedEmptySourceIDs: Set<String> = []
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
Expand Down Expand Up @@ -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(
Expand Down
6 changes: 3 additions & 3 deletions Sources/CodexBar/SpendDashboardModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
19 changes: 18 additions & 1 deletion Sources/CodexBar/UsageStore+Refresh.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Expand Down
17 changes: 16 additions & 1 deletion Sources/CodexBar/UsageStore+TokenCost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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()
Expand All @@ -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:
Comment thread
Chipagosfinest marked this conversation as resolved.
true
default:
false
Expand Down
94 changes: 89 additions & 5 deletions Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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)
}
}

Expand All @@ -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 }
Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
59 changes: 59 additions & 0 deletions Sources/CodexBarCore/Providers/XAI/XAICostUsageMapping.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading