diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index dce63bf76..245efcd16 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -128,6 +128,13 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin if provider == .factory { return snapshot?.secondary ?? snapshot?.primary } + if provider == .copilot, + let primary = snapshot?.primary, + let secondary = snapshot?.secondary + { + // Copilot can expose chat + completions quotas; show the more constrained one by default. + return primary.usedPercent >= secondary.usedPercent ? primary : secondary + } return snapshot?.primary ?? snapshot?.secondary } } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 84280b3c0..50c00c802 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -313,13 +313,7 @@ final class UsageStore { var highest: (provider: UsageProvider, usedPercent: Double)? for provider in self.enabledProviders() { guard let snapshot = self.snapshots[provider] else { continue } - // Use the same window selection logic as menuBarPercentWindow: - // Factory uses secondary (premium) first, others use primary (session) first. - let window: RateWindow? = if provider == .factory { - snapshot.secondary ?? snapshot.primary - } else { - snapshot.primary ?? snapshot.secondary - } + let window = self.menuBarMetricWindowForHighestUsage(provider: provider, snapshot: snapshot) let percent = window?.usedPercent ?? 0 // Skip providers already at 100% - they're fully rate-limited guard percent < 100 else { continue } @@ -330,6 +324,33 @@ final class UsageStore { return highest } + private func menuBarMetricWindowForHighestUsage(provider: UsageProvider, snapshot: UsageSnapshot) -> RateWindow? { + switch self.settings.menuBarMetricPreference(for: provider) { + case .primary: + return snapshot.primary ?? snapshot.secondary + case .secondary: + return snapshot.secondary ?? snapshot.primary + case .average: + guard let primary = snapshot.primary, let secondary = snapshot.secondary else { + return snapshot.primary ?? snapshot.secondary + } + let usedPercent = (primary.usedPercent + secondary.usedPercent) / 2 + return RateWindow(usedPercent: usedPercent, windowMinutes: nil, resetsAt: nil, resetDescription: nil) + case .automatic: + if provider == .factory { + return snapshot.secondary ?? snapshot.primary + } + if provider == .copilot, + let primary = snapshot.primary, + let secondary = snapshot.secondary + { + // Copilot can expose chat + completions quotas; rank by the more constrained one. + return primary.usedPercent >= secondary.usedPercent ? primary : secondary + } + return snapshot.primary ?? snapshot.secondary + } + } + var statusChecksEnabled: Bool { self.settings.statusChecksEnabled } @@ -539,8 +560,21 @@ final class UsageStore { } func handleSessionQuotaTransition(provider: UsageProvider, snapshot: UsageSnapshot) { - guard let primary = snapshot.primary else { return } - let currentRemaining = primary.remainingPercent + // Session quota notifications are tied to the primary session window. Copilot free plans can + // expose only chat quota, so allow Copilot to fall back to secondary for transition tracking. + let sessionWindow: RateWindow? = if let primary = snapshot.primary { + primary + } else if provider == .copilot { + snapshot.secondary + } else { + nil + } + + guard let sessionWindow else { + self.lastKnownSessionRemaining.removeValue(forKey: provider) + return + } + let currentRemaining = sessionWindow.remainingPercent let previousRemaining = self.lastKnownSessionRemaining[provider] defer { self.lastKnownSessionRemaining[provider] = currentRemaining } diff --git a/Sources/CodexBarCore/CopilotUsageModels.swift b/Sources/CodexBarCore/CopilotUsageModels.swift index cd43664f6..40b1a3d74 100644 --- a/Sources/CodexBarCore/CopilotUsageModels.swift +++ b/Sources/CodexBarCore/CopilotUsageModels.swift @@ -1,11 +1,29 @@ import Foundation public struct CopilotUsageResponse: Sendable, Decodable { + private struct AnyCodingKey: CodingKey { + let stringValue: String + let intValue: Int? + + init?(stringValue: String) { + self.stringValue = stringValue + self.intValue = nil + } + + init?(intValue: Int) { + self.stringValue = String(intValue) + self.intValue = intValue + } + } + public struct QuotaSnapshot: Sendable, Decodable { public let entitlement: Double public let remaining: Double public let percentRemaining: Double public let quotaId: String + public var isPlaceholder: Bool { + self.entitlement == 0 && self.remaining == 0 && self.percentRemaining == 0 && self.quotaId.isEmpty + } private enum CodingKeys: String, CodingKey { case entitlement @@ -13,6 +31,63 @@ public struct CopilotUsageResponse: Sendable, Decodable { case percentRemaining = "percent_remaining" case quotaId = "quota_id" } + + public init( + entitlement: Double, + remaining: Double, + percentRemaining: Double, + quotaId: String) + { + self.entitlement = entitlement + self.remaining = remaining + self.percentRemaining = percentRemaining + self.quotaId = quotaId + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.entitlement = try container.decodeIfPresent(Double.self, forKey: .entitlement) ?? 0 + self.remaining = try container.decodeIfPresent(Double.self, forKey: .remaining) ?? 0 + self.percentRemaining = try container.decodeIfPresent(Double.self, forKey: .percentRemaining) ?? 0 + self.quotaId = try container.decodeIfPresent(String.self, forKey: .quotaId) ?? "" + } + } + + public struct QuotaCounts: Sendable, Decodable { + public let chat: Double? + public let completions: Double? + + private enum CodingKeys: String, CodingKey { + case chat + case completions + } + + public init(chat: Double?, completions: Double?) { + self.chat = chat + self.completions = completions + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.chat = Self.decodeNumberIfPresent(container: container, key: .chat) + self.completions = Self.decodeNumberIfPresent(container: container, key: .completions) + } + + private static func decodeNumberIfPresent( + container: KeyedDecodingContainer, + key: CodingKeys) -> Double? + { + if let value = try? container.decodeIfPresent(Double.self, forKey: key) { + return value + } + if let value = try? container.decodeIfPresent(Int.self, forKey: key) { + return Double(value) + } + if let value = try? container.decodeIfPresent(String.self, forKey: key) { + return Double(value) + } + return nil + } } public struct QuotaSnapshots: Sendable, Decodable { @@ -23,17 +98,155 @@ public struct CopilotUsageResponse: Sendable, Decodable { case premiumInteractions = "premium_interactions" case chat } + + public init(premiumInteractions: QuotaSnapshot?, chat: QuotaSnapshot?) { + self.premiumInteractions = premiumInteractions + self.chat = chat + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + var premium = try container.decodeIfPresent(QuotaSnapshot.self, forKey: .premiumInteractions) + var chat = try container.decodeIfPresent(QuotaSnapshot.self, forKey: .chat) + + if premium == nil || chat == nil { + let dynamic = try decoder.container(keyedBy: AnyCodingKey.self) + var fallbackPremium: QuotaSnapshot? + var fallbackChat: QuotaSnapshot? + var firstUsable: QuotaSnapshot? + + for key in dynamic.allKeys { + let value: QuotaSnapshot + do { + guard let decoded = try dynamic.decodeIfPresent(QuotaSnapshot.self, forKey: key) else { + continue + } + guard !decoded.isPlaceholder else { continue } + value = decoded + } catch { + continue + } + + let name = key.stringValue.lowercased() + if firstUsable == nil { + firstUsable = value + } + + if fallbackChat == nil, name.contains("chat") { + fallbackChat = value + continue + } + + if fallbackPremium == nil, + name.contains("premium") || name.contains("completion") || name.contains("code") + { + fallbackPremium = value + } + } + + if premium == nil { + premium = fallbackPremium + } + if chat == nil { + chat = fallbackChat + } + if premium == nil, chat == nil { + // If keys are unfamiliar, still expose one usable quota instead of failing. + chat = firstUsable + } + } + + self.premiumInteractions = premium + self.chat = chat + } } public let quotaSnapshots: QuotaSnapshots public let copilotPlan: String - public let assignedDate: String - public let quotaResetDate: String + public let assignedDate: String? + public let quotaResetDate: String? private enum CodingKeys: String, CodingKey { case quotaSnapshots = "quota_snapshots" case copilotPlan = "copilot_plan" case assignedDate = "assigned_date" case quotaResetDate = "quota_reset_date" + case monthlyQuotas = "monthly_quotas" + case limitedUserQuotas = "limited_user_quotas" + } + + public init( + quotaSnapshots: QuotaSnapshots, + copilotPlan: String, + assignedDate: String?, + quotaResetDate: String?) + { + self.quotaSnapshots = quotaSnapshots + self.copilotPlan = copilotPlan + self.assignedDate = assignedDate + self.quotaResetDate = quotaResetDate + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let directSnapshots = try container.decodeIfPresent(QuotaSnapshots.self, forKey: .quotaSnapshots) + let monthlyQuotas = try container.decodeIfPresent(QuotaCounts.self, forKey: .monthlyQuotas) + let limitedUserQuotas = try container.decodeIfPresent(QuotaCounts.self, forKey: .limitedUserQuotas) + let monthlyLimitedSnapshots = Self.makeQuotaSnapshots(monthly: monthlyQuotas, limited: limitedUserQuotas) + if let directSnapshots, Self.hasUsableQuota(in: directSnapshots) { + self.quotaSnapshots = directSnapshots + } else if let monthlyLimitedSnapshots { + self.quotaSnapshots = monthlyLimitedSnapshots + } else { + self.quotaSnapshots = directSnapshots ?? QuotaSnapshots(premiumInteractions: nil, chat: nil) + } + self.copilotPlan = try container.decodeIfPresent(String.self, forKey: .copilotPlan) ?? "unknown" + self.assignedDate = try container.decodeIfPresent(String.self, forKey: .assignedDate) ?? "" + self.quotaResetDate = try container.decodeIfPresent(String.self, forKey: .quotaResetDate) ?? "" + } + + private static func makeQuotaSnapshots(monthly: QuotaCounts?, limited: QuotaCounts?) -> QuotaSnapshots? { + let premium = Self.makeQuotaSnapshot( + monthly: monthly?.completions, + limited: limited?.completions, + quotaID: "completions") + let chat = Self.makeQuotaSnapshot( + monthly: monthly?.chat, + limited: limited?.chat, + quotaID: "chat") + guard premium != nil || chat != nil else { return nil } + return QuotaSnapshots(premiumInteractions: premium, chat: chat) + } + + private static func makeQuotaSnapshot(monthly: Double?, limited: Double?, quotaID: String) -> QuotaSnapshot? { + guard monthly != nil || limited != nil else { return nil } + guard let monthly else { + // Without a monthly denominator, avoid fabricating a misleading percentage. + return nil + } + + let entitlement = max(0, monthly) + let remaining = max(0, limited ?? monthly) + let percentRemaining: Double = if entitlement > 0 { + max(0, min(100, (remaining / entitlement) * 100)) + } else { + 0 + } + + return QuotaSnapshot( + entitlement: entitlement, + remaining: remaining, + percentRemaining: percentRemaining, + quotaId: quotaID) + } + + private static func hasUsableQuota(in snapshots: QuotaSnapshots) -> Bool { + if let premium = snapshots.premiumInteractions, !premium.isPlaceholder { + return true + } + if let chat = snapshots.chat, !chat.isPlaceholder { + return true + } + return false } } diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift index bc392f205..4623350db 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift @@ -35,9 +35,22 @@ public struct CopilotUsageFetcher: Sendable { } let usage = try JSONDecoder().decode(CopilotUsageResponse.self, from: data) + let premium = self.makeRateWindow(from: usage.quotaSnapshots.premiumInteractions) + let chat = self.makeRateWindow(from: usage.quotaSnapshots.chat) - let primary = self.makeRateWindow(from: usage.quotaSnapshots.premiumInteractions) - let secondary = self.makeRateWindow(from: usage.quotaSnapshots.chat) + let primary: RateWindow? + let secondary: RateWindow? + if let premium { + primary = premium + secondary = chat + } else if let chatWindow = chat { + // Keep chat in the secondary slot so provider labels remain accurate + // ("Premium" for primary, "Chat" for secondary) on chat-only plans. + primary = nil + secondary = chatWindow + } else { + throw URLError(.cannotDecodeRawData) + } let identity = ProviderIdentitySnapshot( providerID: .copilot, @@ -45,7 +58,7 @@ public struct CopilotUsageFetcher: Sendable { accountOrganization: nil, loginMethod: usage.copilotPlan.capitalized) return UsageSnapshot( - primary: primary ?? .init(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + primary: primary, secondary: secondary, tertiary: nil, providerCost: nil, @@ -63,6 +76,7 @@ public struct CopilotUsageFetcher: Sendable { private func makeRateWindow(from snapshot: CopilotUsageResponse.QuotaSnapshot?) -> RateWindow? { guard let snapshot else { return nil } + guard !snapshot.isPlaceholder else { return nil } // percent_remaining is 0-100 based on the JSON example in the web app source let usedPercent = max(0, 100 - snapshot.percentRemaining)