Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -9,6 +9,7 @@
- Widgets: opt-in display of Claude model-scoped weekly quotas (for example Fable) projected from the shared usage snapshot, off by default (#2645). Thanks @alfredjbclaw!

### Fixed
- Claude: let Auto reuse a working CLI fallback when OAuth Keychain access is revoked by Claude Code token rotation, distinguish revoked access from missing credentials, and keep last-known quota history visible with its capture age when every live source fails (#2516). Thanks @axisrow and everyone who supplied forensics!
- Menu: apply the cost summary display style to every provider's menu card, so Submenu only hides inline cost rows for z.ai and other providers (#2976). Thanks @ar0nbg!
- Cost history: align x-axis date labels with their bars in status-menu charts (#2974). Thanks @Yuxin-Qiao!
- Codex: preserve completed empty local history as known-zero usage and spend without fabricating zeroes for incomplete scans (#2932). Thanks @Yuxin-Qiao!
Expand Down
25 changes: 25 additions & 0 deletions Sources/CodexBar/Providers/Claude/ClaudeUIErrorMapper.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import CodexBarCore
import Foundation

@MainActor
enum ClaudeUIErrorMapper {
static func userFacingMessage(
_ raw: String?,
staleSnapshotUpdatedAt: Date?,
localize: (String) -> String = L) -> String?
{
guard let raw else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }

let message = if trimmed == ClaudeOAuthCredentialsError.keychainAccessRevoked.localizedDescription {
localize("claude_oauth_keychain_access_revoked")
} else {
trimmed
}
guard let staleSnapshotUpdatedAt else { return message }
return message + " " + String(
format: localize("claude_showing_last_known_usage"),
staleSnapshotUpdatedAt.relativeDescription())
}
}
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/ar.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1445,3 +1445,5 @@
"5-hour usage" = "الاستخدام خلال 5 ساعات";
"7-day usage" = "الاستخدام خلال 7 أيام";
"Total usage" = "إجمالي الاستخدام";
"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web.";
"claude_showing_last_known_usage" = "Showing last-known usage captured %@.";
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/ca.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1444,3 +1444,5 @@
"5-hour usage" = "Ús de 5 hores";
"7-day usage" = "Ús de 7 dies";
"Total usage" = "Ús total";
"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web.";
"claude_showing_last_known_usage" = "Showing last-known usage captured %@.";
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/de.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1442,3 +1442,5 @@
"5-hour usage" = "5-Stunden-Nutzung";
"7-day usage" = "7-Tage-Nutzung";
"Total usage" = "Gesamtnutzung";
"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web.";
"claude_showing_last_known_usage" = "Showing last-known usage captured %@.";
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
"ollama_safari_cookie_access_hint" = "Safari cookies need Full Disk Access for CodexBar (System Settings > Privacy & Security).";
"ollama_browser_cookie_decryption_denied" = "%@ cookie decryption was declined in Keychain. Open the provider card and click Refresh (⌘R) to request Keychain access again.";
"ollama_browser_cookie_decryption_disabled" = "%@ cookie decryption is disabled in CodexBar; enable Keychain access and refresh.";
"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web.";
"claude_showing_last_known_usage" = "Showing last-known usage captured %@.";

" providers" = " providers";
"(System)" = "(System)";
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/es.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1440,3 +1440,5 @@
"5-hour usage" = "Uso de 5 horas";
"7-day usage" = "Uso de 7 días";
"Total usage" = "Uso total";
"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web.";
"claude_showing_last_known_usage" = "Showing last-known usage captured %@.";
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/fa.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1445,3 +1445,5 @@
"5-hour usage" = "مصرف ۵ ساعته";
"7-day usage" = "مصرف ۷ روزه";
"Total usage" = "مصرف کل";
"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web.";
"claude_showing_last_known_usage" = "Showing last-known usage captured %@.";
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/fr.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1441,3 +1441,5 @@
"5-hour usage" = "Utilisation sur 5 heures";
"7-day usage" = "Utilisation sur 7 jours";
"Total usage" = "Utilisation totale";
"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web.";
"claude_showing_last_known_usage" = "Showing last-known usage captured %@.";
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/gl.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1441,3 +1441,5 @@
"5-hour usage" = "Uso de 5 horas";
"7-day usage" = "Uso de 7 días";
"Total usage" = "Uso total";
"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web.";
"claude_showing_last_known_usage" = "Showing last-known usage captured %@.";
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/id.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1445,3 +1445,5 @@
"5-hour usage" = "Penggunaan 5 jam";
"7-day usage" = "Penggunaan 7 hari";
"Total usage" = "Total penggunaan";
"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web.";
"claude_showing_last_known_usage" = "Showing last-known usage captured %@.";
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/it.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1445,3 +1445,5 @@
"5-hour usage" = "Utilizzo di 5 ore";
"7-day usage" = "Utilizzo di 7 giorni";
"Total usage" = "Utilizzo totale";
"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web.";
"claude_showing_last_known_usage" = "Showing last-known usage captured %@.";
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/ja.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1442,3 +1442,5 @@
"5-hour usage" = "5時間の使用量";
"7-day usage" = "7日間の使用量";
"Total usage" = "合計使用量";
"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web.";
"claude_showing_last_known_usage" = "Showing last-known usage captured %@.";
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/ko.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1409,3 +1409,5 @@
"5-hour usage" = "5시간 사용량";
"7-day usage" = "7일 사용량";
"Total usage" = "총 사용량";
"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web.";
"claude_showing_last_known_usage" = "Showing last-known usage captured %@.";
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/nl.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1441,3 +1441,5 @@
"5-hour usage" = "5-uursgebruik";
"7-day usage" = "7-dagengebruik";
"Total usage" = "Totaal gebruik";
"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web.";
"claude_showing_last_known_usage" = "Showing last-known usage captured %@.";
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/pl.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1445,3 +1445,5 @@
"5-hour usage" = "Użycie 5-godzinne";
"7-day usage" = "Użycie 7-dniowe";
"Total usage" = "Łączne użycie";
"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web.";
"claude_showing_last_known_usage" = "Showing last-known usage captured %@.";
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1442,3 +1442,5 @@
"5-hour usage" = "Uso de 5 horas";
"7-day usage" = "Uso de 7 dias";
"Total usage" = "Uso total";
"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web.";
"claude_showing_last_known_usage" = "Showing last-known usage captured %@.";
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/ru.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1443,3 +1443,5 @@
"5-hour usage" = "Использование за 5 часов";
"7-day usage" = "Использование за 7 дней";
"Total usage" = "Общее использование";
"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web.";
"claude_showing_last_known_usage" = "Showing last-known usage captured %@.";
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/sv.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1440,3 +1440,5 @@
"5-hour usage" = "5-timmarsanvändning";
"7-day usage" = "7-dagarsanvändning";
"Total usage" = "Total användning";
"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web.";
"claude_showing_last_known_usage" = "Showing last-known usage captured %@.";
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/th.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1445,3 +1445,5 @@
"5-hour usage" = "การใช้งาน 5 ชั่วโมง";
"7-day usage" = "การใช้งาน 7 วัน";
"Total usage" = "การใช้งานทั้งหมด";
"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web.";
"claude_showing_last_known_usage" = "Showing last-known usage captured %@.";
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/tr.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1443,3 +1443,5 @@
"5-hour usage" = "5 saatlik kullanım";
"7-day usage" = "7 günlük kullanım";
"Total usage" = "Toplam kullanım";
"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web.";
"claude_showing_last_known_usage" = "Showing last-known usage captured %@.";
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/uk.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1441,3 +1441,5 @@
"5-hour usage" = "Використання за 5 годин";
"7-day usage" = "Використання за 7 днів";
"Total usage" = "Загальне використання";
"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web.";
"claude_showing_last_known_usage" = "Showing last-known usage captured %@.";
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/vi.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1442,3 +1442,5 @@
"5-hour usage" = "Mức sử dụng 5 giờ";
"7-day usage" = "Mức sử dụng 7 ngày";
"Total usage" = "Tổng mức sử dụng";
"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web.";
"claude_showing_last_known_usage" = "Showing last-known usage captured %@.";
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1420,3 +1420,5 @@
"5-hour usage" = "5 小时用量";
"7-day usage" = "7 天用量";
"Total usage" = "总用量";
"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web.";
"claude_showing_last_known_usage" = "Showing last-known usage captured %@.";
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1472,3 +1472,5 @@
"5-hour usage" = "5 小時用量";
"7-day usage" = "7 天用量";
"Total usage" = "總用量";
"claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web.";
"claude_showing_last_known_usage" = "Showing last-known usage captured %@.";
4 changes: 4 additions & 0 deletions Sources/CodexBar/UsageStore+Accessors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,10 @@ extension UsageStore {
switch provider {
case .codex:
return CodexUIErrorMapper.userFacingMessage(raw)
case .claude:
return ClaudeUIErrorMapper.userFacingMessage(
raw,
staleSnapshotUpdatedAt: self.snapshots[provider.instanceID]?.updatedAt)
case .ollama:
return OllamaUIErrorMapper.userFacingMessage(raw)
default:
Expand Down
140 changes: 140 additions & 0 deletions Sources/CodexBar/UsageStore+ClaudeHistoryFallback.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import CodexBarCore
import Foundation

extension UsageStore {
nonisolated static func isClaudeConsumerAutoPipeline(
provider: UsageProvider,
context: ProviderFetchContext,
hasAdminAPIKey: Bool,
hasTokenAccount: Bool) -> Bool
{
provider == .claude && context.sourceMode == .auto && !hasAdminAPIKey && !hasTokenAccount
}

func clearClaudeHistoryFallbackEligibility(provider: UsageProvider) {
guard provider == .claude else { return }
self.claudeHistoryFallbackEligible = false
}

func recordProviderFetchSuccessErrorState(provider: UsageProvider) {
self.errors[provider.instanceID] = nil
self.clearClaudeHistoryFallbackEligibility(provider: provider)
}

@discardableResult
func prepareClaudeHistoryFallback(
provider: UsageProvider,
usesConsumerAutoPipeline: Bool,
accountStateWasStable: Bool) -> Bool
{
guard provider == .claude else { return false }
let eligible = usesConsumerAutoPipeline && accountStateWasStable
self.claudeHistoryFallbackEligible = eligible
return eligible && self.restoreClaudeHistorySnapshotIfNeeded()
}

nonisolated static func shouldPreservePriorSnapshot(after error: Error, hadPriorData: Bool) -> Bool {
guard hadPriorData else { return false }
if error is CancellationError {
return true
}
if self.isPreservableNetworkTransportError(error) {
return true
}

let message = error.localizedDescription.lowercased()
return message.contains("timed out") ||
message.contains("timeout") ||
message.contains("cancelled") ||
message.contains("network connection was lost") ||
message.contains("not connected to the internet")
}

nonisolated static func lastAvailableFailedFetchKind(
from attempts: [ProviderFetchAttempt]) -> ProviderFetchKind?
{
attempts.last { $0.wasAvailable && $0.errorDescription != nil }?.kind
}

nonisolated static func isClaudeCLIRateLimitFailure(_ error: Error) -> Bool {
ClaudeUsageFetcher.isCLIRateLimitError(error)
}

nonisolated static func isClaudeCLIUsageParseFailure(_ error: Error) -> Bool {
if case let ClaudeStatusProbeError.parseFailed(message) = error {
return !ClaudeStatusProbe.isSubscriptionQuotaUnavailableDescription(message)
}
if case let ClaudeUsageError.parseFailed(message) = error {
return !ClaudeStatusProbe.isSubscriptionQuotaUnavailableDescription(message)
}
return false
}

/// Rebuilds only Claude's quota bars from persisted captures. Identity and source authority intentionally stay
/// unset: history proves the percentages and capture time, but it must never impersonate a live account fetch.
@discardableResult
func restoreClaudeHistorySnapshotIfNeeded() -> Bool {
guard self.claudeHistoryFallbackEligible,
self.planUtilizationHistoryLoaded,
self.settings.claudeUsageDataSource == .auto,
self.settings.effectiveSelectedTokenAccount(for: .claude) == nil,
self.snapshots[.claude] == nil
else {
return false
}

let histories = self.planUtilizationHistorySelection(for: .claude).histories
let session = Self.latestClaudeHistoryEntry(
named: .session,
preferredWindowMinutes: Self.sessionWindowMinutes,
histories: histories)
let weekly = Self.latestClaudeHistoryEntry(
named: .weekly,
preferredWindowMinutes: Self.weeklyWindowMinutes,
histories: histories)
let opus = Self.latestClaudeHistoryEntry(
named: .opus,
preferredWindowMinutes: Self.weeklyWindowMinutes,
histories: histories)
let captures = [session?.entry.capturedAt, weekly?.entry.capturedAt, opus?.entry.capturedAt]
.compactMap(\.self)
guard let capturedAt = captures.max() else { return false }

let snapshot = UsageSnapshot(
primary: session.map(Self.rateWindow(from:)),
secondary: weekly.map(Self.rateWindow(from:)),
tertiary: opus.map(Self.rateWindow(from:)),
updatedAt: capturedAt,
dataConfidence: .percentOnly)
self.snapshots[.claude] = snapshot
self.lastKnownResetSnapshots[.claude] = snapshot
return true
}

private nonisolated static func latestClaudeHistoryEntry(
named name: PlanUtilizationSeriesName,
preferredWindowMinutes: Int,
histories: [PlanUtilizationSeriesHistory])
-> (windowMinutes: Int, entry: PlanUtilizationHistoryEntry)?
{
let named = histories.filter { $0.name == name && !$0.entries.isEmpty }
let candidates = named.filter { $0.windowMinutes == preferredWindowMinutes }.isEmpty
? named
: named.filter { $0.windowMinutes == preferredWindowMinutes }
return candidates.compactMap { history in
history.entries.last.map { (history.windowMinutes, $0) }
}.max { lhs, rhs in
lhs.1.capturedAt < rhs.1.capturedAt
}
}

private nonisolated static func rateWindow(
from value: (windowMinutes: Int, entry: PlanUtilizationHistoryEntry)) -> RateWindow
{
RateWindow(
usedPercent: value.entry.usedPercent,
windowMinutes: value.windowMinutes,
resetsAt: value.entry.resetsAt,
resetDescription: nil)
}
}
1 change: 1 addition & 0 deletions Sources/CodexBar/UsageStore+PlanUtilizationLoading.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ extension UsageStore {
self.planUtilizationHistory = loaded
self.planUtilizationHistoryLoaded = true
self.planUtilizationHistoryRevision &+= 1
_ = self.restoreClaudeHistorySnapshotIfNeeded()
}
}
}
Loading
Loading