Skip to content
Open
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
7 changes: 7 additions & 0 deletions Sources/CodexBar/StatusItemController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
52 changes: 43 additions & 9 deletions Sources/CodexBar/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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
}
Expand Down Expand Up @@ -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 }
Expand Down
217 changes: 215 additions & 2 deletions Sources/CodexBarCore/CopilotUsageModels.swift
Original file line number Diff line number Diff line change
@@ -1,18 +1,93 @@
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
case remaining
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<CodingKeys>,
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 {
Expand All @@ -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
}
}
20 changes: 17 additions & 3 deletions Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,30 @@ 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
Comment on lines +49 to +50

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Map chat-only Copilot quota into primary window

When premium data is absent, this branch leaves primary nil and stores chat quota only in secondary. The session notification pipeline (UsageStore.handleSessionQuotaTransition) is keyed to snapshot.primary, so chat-only Copilot accounts never produce depleted/restored session transitions even when their only quota window changes state. Falling back to chat as primary (or adding a transition fallback) avoids disabling notifications for free-plan users.

Useful? React with 👍 / 👎.

} else {
throw URLError(.cannotDecodeRawData)
}

let identity = ProviderIdentitySnapshot(
providerID: .copilot,
accountEmail: nil,
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,
Expand All @@ -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)

Expand Down