From 8c79ce123609c6f4d94090fc147d4f21ce734d54 Mon Sep 17 00:00:00 2001 From: Emanuel Stadler <9994339+emanuelst@users.noreply.github.com> Date: Wed, 18 Feb 2026 04:40:57 +0100 Subject: [PATCH 1/2] Fix Copilot free-plan usage parsing and menu metric --- Sources/CodexBar/StatusItemController.swift | 7 + Sources/CodexBar/UsageStore.swift | 35 ++- Sources/CodexBarCore/CopilotUsageModels.swift | 209 ++++++++++++++++++ .../Copilot/CopilotUsageFetcher.swift | 19 +- 4 files changed, 260 insertions(+), 10 deletions(-) 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..4898795a0 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 } diff --git a/Sources/CodexBarCore/CopilotUsageModels.swift b/Sources/CodexBarCore/CopilotUsageModels.swift index cd43664f6..0f18a6170 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,6 +98,67 @@ 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 @@ -35,5 +171,78 @@ public struct CopilotUsageResponse: Sendable, Decodable { 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 } + + let entitlement = max(0, monthly ?? 0) + let remaining = max(0, limited ?? monthly ?? 0) + 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..06b41ac5e 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift @@ -35,9 +35,21 @@ 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 chat { + // Keep chat in the secondary slot so it remains labeled as "Chat" in the menu. + primary = nil + secondary = chat + } else { + throw URLError(.cannotDecodeRawData) + } let identity = ProviderIdentitySnapshot( providerID: .copilot, @@ -45,7 +57,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 +75,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) From e6425847a3ae97e4c2f3a1129561f9406ee6f26c Mon Sep 17 00:00:00 2001 From: hzyu-hub Date: Sat, 7 Feb 2026 05:49:41 -0500 Subject: [PATCH 2/2] fix: make assignedDate and quotaResetDate optional in CopilotUsageModels GitHub's /copilot_internal/user API returns null for assigned_date and quota_reset_date on trial/free Copilot plans, causing a DecodingError.valueNotFound when decoding the JSON response. This change makes both fields optional (String?) to handle null values gracefully. Fixes #162 (cherry picked from commit 8a470bdaf101bc855ddaf476b7a95f4d88d852d8) --- Sources/CodexBar/UsageStore.swift | 17 +++++++++++++++-- Sources/CodexBarCore/CopilotUsageModels.swift | 12 ++++++++---- .../Providers/Copilot/CopilotUsageFetcher.swift | 7 ++++--- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 4898795a0..50c00c802 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -560,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 0f18a6170..40b1a3d74 100644 --- a/Sources/CodexBarCore/CopilotUsageModels.swift +++ b/Sources/CodexBarCore/CopilotUsageModels.swift @@ -163,8 +163,8 @@ public struct CopilotUsageResponse: Sendable, Decodable { 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" @@ -220,9 +220,13 @@ public struct CopilotUsageResponse: Sendable, Decodable { 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 ?? 0) - let remaining = max(0, limited ?? monthly ?? 0) + 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 { diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift index 06b41ac5e..4623350db 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift @@ -43,10 +43,11 @@ public struct CopilotUsageFetcher: Sendable { if let premium { primary = premium secondary = chat - } else if let chat { - // Keep chat in the secondary slot so it remains labeled as "Chat" in the menu. + } 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 = chat + secondary = chatWindow } else { throw URLError(.cannotDecodeRawData) }