diff --git a/CHANGELOG.md b/CHANGELOG.md index e627d03cb8..e41a0879c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.50.1 — Unreleased +### Added +- Copilot: add a per-seat "Credits used" row with a user-entered credit entitlement that turns the row into a usage bar — GitHub publishes no credit entitlement on any documented endpoint, so a row without one stays plain text, and the row only appears when it carries real signal (token-based billing, unlimited quota, or actual consumption) (#2593). Thanks @KSEGIT! + ## 0.50.0 — 2026-08-15 ### Added diff --git a/Sources/CodexBar/MenuCardHeightFingerprint.swift b/Sources/CodexBar/MenuCardHeightFingerprint.swift index 6906a242d1..1245d0a0a8 100644 --- a/Sources/CodexBar/MenuCardHeightFingerprint.swift +++ b/Sources/CodexBar/MenuCardHeightFingerprint.swift @@ -43,9 +43,11 @@ extension [ProviderDetailSection] { MenuCardHeightFingerprint.field("title", section.title), MenuCardHeightFingerprint.join(section.rows.map { row in MenuCardHeightFingerprint.join([ + MenuCardHeightFingerprint.field("id", row.id), MenuCardHeightFingerprint.field("label", row.label), MenuCardHeightFingerprint.field("value", row.value), MenuCardHeightFingerprint.field("secondary", row.secondaryValue), + "progress=\(row.progress == nil ? "0" : "1")", ]) }), section.chart.map { chart in diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 1bbf082c03..0b4197d033 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1044,11 +1044,13 @@ extension UsageMenuCardView.Model { return details.compactMap { section in let rows = section.rows.compactMap { row in try? ProviderDetailSection.Row( + id: row.id, label: PersonalInfoRedactor.redactEmails(in: row.label, isEnabled: true) ?? row.label, value: PersonalInfoRedactor.redactEmails(in: row.value, isEnabled: true) ?? row.value, secondaryValue: PersonalInfoRedactor.redactEmails( in: row.secondaryValue, - isEnabled: true)) + isEnabled: true), + progress: row.progress) } let chart = section.chart.flatMap { chart in let points = chart.points.compactMap { point in diff --git a/Sources/CodexBar/ProviderDetailSectionsContent.swift b/Sources/CodexBar/ProviderDetailSectionsContent.swift index c5723bb58d..0eca3a44be 100644 --- a/Sources/CodexBar/ProviderDetailSectionsContent.swift +++ b/Sources/CodexBar/ProviderDetailSectionsContent.swift @@ -28,23 +28,33 @@ struct ProviderDetailSectionsContent: View { .lineLimit(1) } ForEach(Array(section.rows.enumerated()), id: \.offset) { _, row in - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(row.label) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - Spacer(minLength: 8) - VStack(alignment: .trailing, spacing: 1) { - Text(row.value) - .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) - .fontWeight(.medium) - if let secondaryValue = row.secondaryValue { - Text(secondaryValue) - .font(.caption2) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + VStack(alignment: .leading, spacing: 3) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(row.label) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + Spacer(minLength: 8) + VStack(alignment: .trailing, spacing: 1) { + Text(row.value) + .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) + .fontWeight(.medium) + if let secondaryValue = row.secondaryValue { + Text(secondaryValue) + .font(.caption2) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + } } } + .font(.caption) + .lineLimit(1) + if let progress = row.progress { + // The ratio is provider data left unclamped by contract; only the bar's fill + // is clamped here, the "used / total" caption keeps the raw numbers. + UsageProgressBar( + percent: min(100, max(0, progress.usedPercent)), + tint: self.chartColor, + accessibilityLabel: row.label) + } } - .font(.caption) - .lineLimit(1) } if let chart = section.chart { ProviderDetailChartContent(chart: chart, color: self.chartColor) diff --git a/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift index a7904b4ea8..74d418570c 100644 --- a/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift @@ -18,6 +18,7 @@ struct CopilotProviderImplementation: ProviderImplementation { _ = settings.copilotBudgetExtrasEnabled _ = settings.copilotBudgetCookieSource _ = settings.copilotBudgetCookieHeader + _ = settings.copilotSeatCreditEntitlementRaw } @MainActor @@ -155,7 +156,15 @@ struct CopilotProviderImplementation: ProviderImplementation { @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { - [ + let seatEntitlementBinding = Binding( + get: { context.settings.copilotEffectiveSeatCreditEntitlementRaw }, + set: { newValue in + context.settings.copilotEffectiveSeatCreditEntitlementRaw = newValue + // Rewrite the cached row locally so a stale denominator/bar never survives a failed + // (or offline) refresh; no network call here. + context.store.updateCopilotSeatCreditEntitlement(CopilotCreditEntitlementParser.parse(newValue)) + }) + return [ ProviderSettingsFieldDescriptor( id: "copilot-budget-cookie-header", title: "Manual GitHub Cookie header", @@ -189,6 +198,17 @@ struct CopilotProviderImplementation: ProviderImplementation { actions: [], isVisible: nil, onActivate: nil), + ProviderSettingsFieldDescriptor( + id: "copilot-seat-credit-entitlement", + title: "Included AI credits (per seat)", + subtitle: "GitHub does not publish this value. Enter it to show a usage bar. " + + "Applies to the selected GitHub account.", + kind: .plain, + placeholder: "e.g. 3000", + binding: seatEntitlementBinding, + actions: [], + isVisible: nil, + onActivate: nil), ProviderSettingsFieldDescriptor( id: "copilot-add-account", title: "GitHub Login", diff --git a/Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift b/Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift index 4fdf677eef..61efbd7bf5 100644 --- a/Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift +++ b/Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift @@ -70,6 +70,26 @@ extension SettingsStore { } extension SettingsStore { + /// Effective per-seat AI credit allowance raw value: the selected account's override when set, + /// otherwise the global fallback. Writes go to the selected account when one is configured, + /// else to the global fallback so users without saved accounts are unaffected. + var copilotEffectiveSeatCreditEntitlementRaw: String { + get { + self.effectiveSelectedTokenAccount(for: .copilot)?.sanitizedSeatCreditEntitlement + ?? self.copilotSeatCreditEntitlementRaw + } + set { + if let account = self.effectiveSelectedTokenAccount(for: .copilot) { + self.updateTokenAccount( + provider: .copilot, + accountID: account.id, + seatCreditEntitlement: newValue) + } else { + self.copilotSeatCreditEntitlementRaw = newValue + } + } + } + func copilotSettingsSnapshot( tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.CopilotProviderSettings { @@ -79,12 +99,16 @@ extension SettingsStore { override: tokenOverride) let token = account?.token ?? self.copilotAPIToken let host = CopilotDeviceFlow.normalizedHost(self.copilotEnterpriseHost) + // Per-account allowances win; the global UserDefaults values remain the fallback for + // legacy installs and accounts that never set one (migration path, keys kept on purpose). + let seatEntitlementRaw = account?.sanitizedSeatCreditEntitlement ?? self.copilotSeatCreditEntitlementRaw return ProviderSettingsSnapshot.CopilotProviderSettings( apiToken: self.normalizedConfigValue(token), enterpriseHost: host == CopilotDeviceFlow.defaultHost ? nil : host, selectedAccountExternalIdentifier: account?.externalIdentifier.flatMap(self.normalizedConfigValue), budgetExtrasEnabled: self.copilotBudgetExtrasEnabled, budgetCookieSource: self.copilotBudgetCookieSource, - manualBudgetCookieHeader: self.normalizedConfigValue(self.copilotBudgetCookieHeader)) + manualBudgetCookieHeader: self.normalizedConfigValue(self.copilotBudgetCookieHeader), + seatCreditEntitlement: CopilotCreditEntitlementParser.parse(seatEntitlementRaw)) } } diff --git a/Sources/CodexBar/Providers/Copilot/UsageStore+CopilotCredits.swift b/Sources/CodexBar/Providers/Copilot/UsageStore+CopilotCredits.swift new file mode 100644 index 0000000000..3d3eb16a98 --- /dev/null +++ b/Sources/CodexBar/Providers/Copilot/UsageStore+CopilotCredits.swift @@ -0,0 +1,65 @@ +import CodexBarCore +import Foundation + +@MainActor +extension UsageStore { + /// Rewrites the seat "Credits used" row when the user changes or clears the per-seat entitlement, + /// mirroring `clearCopilotBudgetExtras()`. Called synchronously from the settings field's binding + /// setter so editing the value cannot leave a stale denominator/bar on the card if the follow-up + /// refresh never lands (offline, token lost, 401) and the last-good snapshot is retained. + func updateCopilotSeatCreditEntitlement(_ entitlement: Double?) { + if let snapshot = self.snapshots[.copilot], + let updated = snapshot.updatingCopilotSeatCreditEntitlement(entitlement) + { + self.snapshots[.copilot] = updated + self.lastKnownResetSnapshots[.copilot] = updated + } else if let resetSnapshot = self.lastKnownResetSnapshots[.copilot], + let updated = resetSnapshot.updatingCopilotSeatCreditEntitlement(entitlement) + { + self.lastKnownResetSnapshots[.copilot] = updated + } + } +} + +extension UsageSnapshot { + /// Returns a copy with the seat credits row rebuilt for `entitlement`, or `nil` when nothing + /// changed (no row, or a row that carries no numeric usage at all — the next refresh must + /// rebuild it). The numerator comes from `row.progress.used`, falling back to the retained + /// `row.usageValue` on text-only rows, never re-parsed from the display string. That fallback + /// is what lets a cached text-only row grow a bar the moment an entitlement is entered, even + /// when the follow-up refresh never lands (offline, token lost, 401). + func updatingCopilotSeatCreditEntitlement(_ entitlement: Double?) -> UsageSnapshot? { + guard self.details.lazy.flatMap(\.rows) + .contains(where: { + $0.id == CopilotCreditDetailRows.seatRowID && ($0.progress != nil || $0.usageValue != nil) + }) + else { return nil } + let details = self.details.map { section -> ProviderDetailSection in + let rows = section.rows.map { row -> ProviderDetailSection.Row in + guard row.id == CopilotCreditDetailRows.seatRowID, let used = row.progress?.used ?? row.usageValue + else { return row } + let value: String + let progress: ProviderDetailSection.Row.Progress? + if let entitlement { + guard let rebuilt = try? ProviderDetailSection.Row.Progress(used: used, total: entitlement) + else { return row } + value = "\(UsageFormatter.creditsNumberString(from: used)) / " + + UsageFormatter.creditsNumberString(from: entitlement) + progress = rebuilt + } else { + value = UsageFormatter.creditsNumberString(from: used) + progress = nil + } + return (try? ProviderDetailSection.Row( + id: row.id, + label: row.label, + value: value, + secondaryValue: row.secondaryValue, + progress: progress, + usageValue: used)) ?? row + } + return (try? ProviderDetailSection(title: section.title, rows: rows, chart: section.chart)) ?? section + } + return self.with(details: details) + } +} diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 4c7f74c01f..c53de6c12f 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -677,6 +677,15 @@ extension SettingsStore { } } + var copilotSeatCreditEntitlementRaw: String { + get { self.defaultsState.copilotSeatCreditEntitlementRaw } + set { + self.defaultsState.copilotSeatCreditEntitlementRaw = newValue + self.userDefaults.set(newValue, forKey: "copilotSeatCreditEntitlement") + self.noteBackgroundWorkSettingsChanged() + } + } + private var claudeWebExtrasEnabledRaw: Bool { get { self.defaultsState.claudeWebExtrasEnabledRaw } set { diff --git a/Sources/CodexBar/SettingsStore+TokenAccounts.swift b/Sources/CodexBar/SettingsStore+TokenAccounts.swift index 722fe55c1a..5c1b8b2cf2 100644 --- a/Sources/CodexBar/SettingsStore+TokenAccounts.swift +++ b/Sources/CodexBar/SettingsStore+TokenAccounts.swift @@ -111,7 +111,8 @@ extension SettingsStore { externalIdentifier: String?? = nil, usageScope: String?? = nil, organizationID: String?? = nil, - workspaceID: String?? = nil) + workspaceID: String?? = nil, + seatCreditEntitlement: String?? = nil) { guard let data = self.tokenAccountsData(for: provider), !data.accounts.isEmpty else { return } guard let index = data.accounts.firstIndex(where: { $0.id == accountID }) else { return } @@ -151,6 +152,13 @@ extension SettingsStore { } else { resolvedWorkspaceID = existing.workspaceID } + let resolvedSeatCreditEntitlement: String? + if let seatCreditEntitlement { + let trimmed = seatCreditEntitlement?.trimmingCharacters(in: .whitespacesAndNewlines) + resolvedSeatCreditEntitlement = (trimmed?.isEmpty ?? true) ? nil : trimmed + } else { + resolvedSeatCreditEntitlement = existing.seatCreditEntitlement + } let updatedAccount = ProviderTokenAccount( id: existing.id, label: (trimmedLabel?.isEmpty == false) ? trimmedLabel! : existing.label, @@ -160,7 +168,8 @@ extension SettingsStore { externalIdentifier: resolvedIdentifier, usageScope: resolvedUsageScope, organizationID: resolvedOrganizationID, - workspaceID: resolvedWorkspaceID) + workspaceID: resolvedWorkspaceID, + seatCreditEntitlement: resolvedSeatCreditEntitlement) var accounts = data.accounts accounts[index] = updatedAccount diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index ef5eaa3d7b..f973effcd4 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -478,6 +478,8 @@ extension SettingsStore { let menuBarLayoutVerticalAdjustment = max(-20, min(20, rawVerticalAdjustment ?? 0)) let copilotBudgetExtrasEnabled = userDefaults.object(forKey: "copilotBudgetExtrasEnabled") as? Bool ?? false let copilotIconSecondaryWindowIDRaw = Self.loadCopilotIconSecondaryWindowIDRaw(userDefaults: userDefaults) + let copilotSeatCreditEntitlementRaw = userDefaults.object( + forKey: "copilotSeatCreditEntitlement") as? String ?? "" let costUsageEnabled = userDefaults.object(forKey: "tokenCostUsageEnabled") as? Bool ?? false let codexLocalSessionCostLedgerEnabled = userDefaults.object( forKey: "codexLocalSessionCostLedgerEnabled") as? Bool ?? false @@ -599,6 +601,7 @@ extension SettingsStore { menuBarLayoutVerticalAdjustment: menuBarLayoutVerticalAdjustment, copilotBudgetExtrasEnabled: copilotBudgetExtrasEnabled, copilotIconSecondaryWindowIDRaw: copilotIconSecondaryWindowIDRaw, + copilotSeatCreditEntitlementRaw: copilotSeatCreditEntitlementRaw, costUsageEnabled: costUsageEnabled, codexLocalSessionCostLedgerEnabled: codexLocalSessionCostLedgerEnabled, costUsageHistoryDays: costUsageHistoryDays, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index e83b03b1e9..d949c0b0cb 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -43,6 +43,7 @@ struct SettingsDefaultsState { var menuBarLayoutVerticalAdjustment: Int var copilotBudgetExtrasEnabled: Bool var copilotIconSecondaryWindowIDRaw: String + var copilotSeatCreditEntitlementRaw: String var costUsageEnabled: Bool var codexLocalSessionCostLedgerEnabled: Bool var costUsageHistoryDays: Int diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index d6dec7c704..e19a7c21c8 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -200,7 +200,8 @@ struct TokenAccountCLIContext { externalIdentifier: existing.externalIdentifier, usageScope: existing.usageScope, organizationID: existing.organizationID, - workspaceID: existing.workspaceID) + workspaceID: existing.workspaceID, + seatCreditEntitlement: existing.seatCreditEntitlement) providerConfig.tokenAccounts = ProviderTokenAccountData( version: data.version, accounts: accounts, diff --git a/Sources/CodexBarCore/ProviderDetailSection.swift b/Sources/CodexBarCore/ProviderDetailSection.swift index 9aa49a7e9b..ee28d7acb2 100644 --- a/Sources/CodexBarCore/ProviderDetailSection.swift +++ b/Sources/CodexBarCore/ProviderDetailSection.swift @@ -7,28 +7,98 @@ public struct ProviderDetailSection: Codable, Equatable, Sendable { public static let maximumStringLength = 120 public struct Row: Codable, Equatable, Sendable { + /// Numeric progress behind a row, when the provider knows both sides of a ratio. + /// + /// Rows are display strings by design; the bar needs real numbers, so a row that should + /// render a progress indicator carries the ratio here instead of a second representation. + public struct Progress: Codable, Equatable, Sendable { + public let used: Double + public let total: Double + + /// Percent of the total consumed. + /// + /// Intentionally not clamped: overage-permitted plans can exceed 100%, and `RateWindow` + /// documents the same convention of leaving provider values un-normalized. Display + /// clamping belongs to the renderer. + public var usedPercent: Double { + (self.used / self.total) * 100 + } + + public init(used: Double, total: Double) throws { + guard used.isFinite, total.isFinite else { + throw ValidationError("row.progress values must be finite") + } + guard total > 0 else { + throw ValidationError("row.progress.total must be positive") + } + self.used = used + self.total = total + } + + private enum CodingKeys: String, CodingKey { + case used + case total + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + try self.init( + used: container.decode(Double.self, forKey: .used), + total: container.decode(Double.self, forKey: .total)) + } + } + + /// Optional stable identifier, for rows a feature must find again (tests, clearing a + /// specific row from a stored snapshot). Not required for purely presentational rows. + public let id: String? public let label: String public let value: String public let secondaryValue: String? + public let progress: Progress? + /// Numeric usage behind the row when known, so a cached row can be rebuilt into a ratio + /// (or stripped back to plain text) without parsing the display string. Independent of + /// `progress`, which only exists when both sides of a ratio are known. + public let usageValue: Double? - public init(label: String, value: String, secondaryValue: String? = nil) throws { + public init( + id: String? = nil, + label: String, + value: String, + secondaryValue: String? = nil, + progress: Progress? = nil, + usageValue: Double? = nil) throws + { + self.id = try ProviderDetailSection.optionalString(id, path: "row.id") self.label = try ProviderDetailSection.requiredString(label, path: "row.label") self.value = try ProviderDetailSection.requiredString(value, path: "row.value") self.secondaryValue = try ProviderDetailSection.optionalString(secondaryValue, path: "row.secondaryValue") + self.progress = progress + if let usageValue { + guard usageValue.isFinite else { + throw ValidationError("row.usageValue must be finite") + } + } + self.usageValue = usageValue } private enum CodingKeys: String, CodingKey { + case id case label case value case secondaryValue + case progress + case usageValue } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) try self.init( + id: container.decodeIfPresent(String.self, forKey: .id), label: container.decode(String.self, forKey: .label), value: container.decode(String.self, forKey: .value), - secondaryValue: container.decodeIfPresent(String.self, forKey: .secondaryValue)) + secondaryValue: container.decodeIfPresent(String.self, forKey: .secondaryValue), + progress: container.decodeIfPresent(Progress.self, forKey: .progress), + usageValue: container.decodeIfPresent(Double.self, forKey: .usageValue)) } } @@ -163,12 +233,22 @@ public struct ProviderDetailSection: Codable, Equatable, Sendable { } extension ProviderDetailSection { - static func makeRow(label: String, value: String, secondaryValue: String? = nil) -> Row { + static func makeRow( + id: String? = nil, + label: String, + value: String, + secondaryValue: String? = nil, + progress: Row.Progress? = nil, + usageValue: Double? = nil) -> Row + { do { return try Row( + id: self.boundedOptional(id), label: self.boundedRequired(label), value: self.boundedRequired(value), - secondaryValue: self.boundedOptional(secondaryValue)) + secondaryValue: self.boundedOptional(secondaryValue), + progress: progress, + usageValue: usageValue) } catch { preconditionFailure("Bounded provider detail row failed validation: \(error)") } @@ -218,8 +298,32 @@ extension ProviderDetailSection { } extension ProviderDetailSection.Row { - static func makeRow(label: String, value: String, secondaryValue: String? = nil) -> Self { - ProviderDetailSection.makeRow(label: label, value: value, secondaryValue: secondaryValue) + static func makeRow( + id: String? = nil, + label: String, + value: String, + secondaryValue: String? = nil, + progress: Progress? = nil, + usageValue: Double? = nil) -> Self + { + ProviderDetailSection.makeRow( + id: id, + label: label, + value: value, + secondaryValue: secondaryValue, + progress: progress, + usageValue: usageValue) + } +} + +extension ProviderDetailSection.Row.Progress { + /// Non-throwing variant for call sites that already hold validated numbers. + static func makeProgress(used: Double, total: Double) -> Self { + do { + return try Self(used: used, total: total) + } catch { + preconditionFailure("Bounded provider detail row progress failed validation: \(error)") + } } } diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotCreditEntitlementParser.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotCreditEntitlementParser.swift new file mode 100644 index 0000000000..1de09b914d --- /dev/null +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotCreditEntitlementParser.swift @@ -0,0 +1,15 @@ +import Foundation + +/// Parses the user-entered credit allowance from settings. +/// +/// A blank, non-numeric, or non-positive entry means "no denominator" — the card then shows a text row +/// instead of a bar. Never guess a value here; GitHub does not publish the entitlement. +public enum CopilotCreditEntitlementParser { + public static func parse(_ raw: String) -> Double? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + // `isFinite` matters: "inf"/"1e999" parse to .infinity, which would crash the progress + // validation downstream. + guard !trimmed.isEmpty, let value = Double(trimmed), value.isFinite, value > 0 else { return nil } + return value + } +} diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift index f1b7b2f541..a3bcbfd416 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift @@ -115,7 +115,8 @@ struct CopilotAPIFetchStrategy: ProviderFetchStrategy { } let fetcher = CopilotUsageFetcher( token: token, - enterpriseHost: context.settings?.copilot?.enterpriseHost) + enterpriseHost: context.settings?.copilot?.enterpriseHost, + seatEntitlement: context.settings?.copilot?.seatCreditEntitlement) let usage = try await fetcher.fetch() let snap = await self.addBudgetWindowsIfNeeded(to: usage, token: token, context: context) return self.makeResult( diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderSettings.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderSettings.swift index 74340776e6..d322fc429e 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderSettings.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderSettings.swift @@ -7,6 +7,9 @@ public struct CopilotProviderSettings: Sendable { public let budgetExtrasEnabled: Bool public let budgetCookieSource: ProviderCookieSource public let manualBudgetCookieHeader: String? + /// User-entered monthly AI credit allowance. GitHub publishes no entitlement on any documented + /// endpoint, so `nil` renders a text row rather than a bar. + public let seatCreditEntitlement: Double? public init( apiToken: String? = nil, @@ -14,7 +17,8 @@ public struct CopilotProviderSettings: Sendable { selectedAccountExternalIdentifier: String? = nil, budgetExtrasEnabled: Bool = false, budgetCookieSource: ProviderCookieSource = .auto, - manualBudgetCookieHeader: String? = nil) + manualBudgetCookieHeader: String? = nil, + seatCreditEntitlement: Double? = nil) { self.apiToken = apiToken self.enterpriseHost = enterpriseHost @@ -22,6 +26,7 @@ public struct CopilotProviderSettings: Sendable { self.budgetExtrasEnabled = budgetExtrasEnabled self.budgetCookieSource = budgetCookieSource self.manualBudgetCookieHeader = manualBudgetCookieHeader + self.seatCreditEntitlement = seatCreditEntitlement } } diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift index 219b1e8146..76813f60ad 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift @@ -3,6 +3,13 @@ import Foundation import FoundationNetworking #endif +/// Stable provider-detail row ids and section title for the Copilot AI credit lane, so a feature +/// can find a row again (tests, menu card rendering). +public enum CopilotCreditDetailRows { + public static let sectionTitle = "Credits" + public static let seatRowID = "copilot-seat-credits" +} + public struct CopilotUsageFetcher: Sendable { public struct GitHubUserIdentity: Decodable, Equatable, Sendable { public let id: Int64 @@ -16,15 +23,18 @@ public struct CopilotUsageFetcher: Sendable { private let token: String private let enterpriseHost: String? + private let seatEntitlement: Double? private let transport: any ProviderHTTPTransport public init( token: String, enterpriseHost: String? = nil, + seatEntitlement: Double? = nil, transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) { self.token = token self.enterpriseHost = enterpriseHost + self.seatEntitlement = seatEntitlement self.transport = transport } @@ -66,21 +76,23 @@ public struct CopilotUsageFetcher: Sendable { } let usage = try JSONDecoder().decode(CopilotUsageResponse.self, from: response.data) + return try self.snapshot(from: usage) + } + + func snapshot(from usage: CopilotUsageResponse) throws -> UsageSnapshot { let resetsAt = Self.parseQuotaResetDate(usage.quotaResetDate) let premiumSnapshot = usage.quotaSnapshots.premiumInteractions let chatSnapshot = usage.quotaSnapshots.chat let premium = Self.makeRateWindow(from: premiumSnapshot, resetsAt: resetsAt) let chat = Self.makeRateWindow(from: chatSnapshot, resetsAt: resetsAt) - let creditsUsed = premiumSnapshot?.creditsUsed ?? chatSnapshot?.creditsUsed - let details: [ProviderDetailSection] = creditsUsed.map { creditsUsed in - [.makeSection(title: "Credits", rows: [ - .makeRow( - label: "Credits used", - value: UsageFormatter.creditsNumberString(from: creditsUsed), - secondaryValue: resetsAt.map { UsageFormatter.resetDescription(from: $0) }), - ])] - } ?? [] let hasUnlimitedQuota = premiumSnapshot?.unlimited == true || chatSnapshot?.unlimited == true + let creditsUsed = premiumSnapshot?.creditsUsed ?? chatSnapshot?.creditsUsed + let details = Self.makeCreditDetails( + creditsUsed: creditsUsed, + seatEntitlement: self.seatEntitlement, + tokenBasedBilling: usage.tokenBasedBilling, + hasUnlimitedQuota: hasUnlimitedQuota, + resetsAt: resetsAt) let primary: RateWindow? let secondary: RateWindow? @@ -171,6 +183,46 @@ public struct CopilotUsageFetcher: Sendable { resetDescription: overQuotaDescription) } + static func makeCreditDetails( + creditsUsed: Double?, + seatEntitlement: Double?, + tokenBasedBilling: Bool, + hasUnlimitedQuota: Bool, + resetsAt: Date?) -> [ProviderDetailSection] + { + guard let creditsUsed else { return [] } + // GitHub reports `credits_used: 0` on metered snapshots too, so the field alone is not + // exclusive to credit-billed seats. Only surface the row when it carries real signal: + // token/unlimited billing, actual consumption, or a user-configured entitlement to track + // against. Otherwise every Copilot Pro/Individual seat would grow a permanent, unremovable + // "0 credits used" row. + let hasSignal = tokenBasedBilling || hasUnlimitedQuota || creditsUsed > 0 || seatEntitlement != nil + guard hasSignal else { return [] } + + let usedLabel = UsageFormatter.creditsNumberString(from: creditsUsed) + let resetText = resetsAt.map { UsageFormatter.resetDescription(from: $0) } + let row: ProviderDetailSection.Row = if let seatEntitlement { + // GitHub publishes no included-credit ceiling on any documented endpoint, so the + // denominator is user-entered; the bar's ratio travels as data on the shared row + // contract while the caption keeps the raw numbers. + .makeRow( + id: CopilotCreditDetailRows.seatRowID, + label: "Credits used", + value: "\(usedLabel) / \(UsageFormatter.creditsNumberString(from: seatEntitlement))", + secondaryValue: resetText, + progress: .makeProgress(used: creditsUsed, total: seatEntitlement), + usageValue: creditsUsed) + } else { + .makeRow( + id: CopilotCreditDetailRows.seatRowID, + label: "Credits used", + value: usedLabel, + secondaryValue: resetText, + usageValue: creditsUsed) + } + return [.makeSection(title: CopilotCreditDetailRows.sectionTitle, rows: [row])] + } + static func parseQuotaResetDate(_ value: String?) -> Date? { guard let raw = value?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil diff --git a/Sources/CodexBarCore/TokenAccounts.swift b/Sources/CodexBarCore/TokenAccounts.swift index 4dfa9578b7..f39706e9b5 100644 --- a/Sources/CodexBarCore/TokenAccounts.swift +++ b/Sources/CodexBarCore/TokenAccounts.swift @@ -18,6 +18,9 @@ public struct ProviderTokenAccount: Codable, Identifiable, Sendable { /// Optional provider-specific workspace/project target. z.ai team accounts /// use this for the BigModel project header. public let workspaceID: String? + /// Optional provider-specific AI credit allowance (raw user-entered value). Copilot accounts + /// use this as the per-seat monthly credit entitlement; GitHub publishes no such entitlement. + public let seatCreditEntitlement: String? enum CodingKeys: String, CodingKey { case id @@ -29,6 +32,7 @@ public struct ProviderTokenAccount: Codable, Identifiable, Sendable { case usageScope case organizationID = "organizationId" case workspaceID + case seatCreditEntitlement } public init( @@ -40,7 +44,8 @@ public struct ProviderTokenAccount: Codable, Identifiable, Sendable { externalIdentifier: String? = nil, usageScope: String? = nil, organizationID: String? = nil, - workspaceID: String? = nil) + workspaceID: String? = nil, + seatCreditEntitlement: String? = nil) { self.id = id self.label = label @@ -51,6 +56,7 @@ public struct ProviderTokenAccount: Codable, Identifiable, Sendable { self.usageScope = usageScope self.organizationID = organizationID self.workspaceID = workspaceID + self.seatCreditEntitlement = seatCreditEntitlement } public var displayName: String { @@ -69,6 +75,10 @@ public struct ProviderTokenAccount: Codable, Identifiable, Sendable { Self.clean(self.workspaceID) } + public var sanitizedSeatCreditEntitlement: String? { + Self.clean(self.seatCreditEntitlement) + } + private static func clean(_ raw: String?) -> String? { let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) return (trimmed?.isEmpty ?? true) ? nil : trimmed @@ -84,7 +94,8 @@ public struct ProviderTokenAccount: Codable, Identifiable, Sendable { externalIdentifier: self.externalIdentifier, usageScope: self.usageScope, organizationID: self.organizationID, - workspaceID: self.workspaceID) + workspaceID: self.workspaceID, + seatCreditEntitlement: self.seatCreditEntitlement) } } diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 928aa7f214..ec76849198 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -236,6 +236,10 @@ public struct UsageSnapshot: Codable, Sendable { self.replacing(extraRateWindows: .value(extraRateWindows)) } + public func with(details: [ProviderDetailSection]) -> UsageSnapshot { + self.replacing(details: .value(details)) + } + public func withCodexResetCredits(_ resetCredits: CodexRateLimitResetCreditsSnapshot?) -> UsageSnapshot { self.replacing(codexResetCredits: .value(resetCredits)) } @@ -376,6 +380,10 @@ public struct UsageSnapshot: Codable, Sendable { self.details.lazy.flatMap(\.rows).first { $0.label == label } } + public func detailRow(id: String) -> ProviderDetailSection.Row? { + self.details.lazy.flatMap(\.rows).first { $0.id == id } + } + public func rateLimitsUnavailable(for provider: UsageProvider) -> Bool { UsageLimitsAvailability.resolve(provider: provider, snapshot: self).isUnavailable } diff --git a/Tests/CodexBarTests/CopilotAccountCreditEntitlementTests.swift b/Tests/CodexBarTests/CopilotAccountCreditEntitlementTests.swift new file mode 100644 index 0000000000..0a9f743a3b --- /dev/null +++ b/Tests/CodexBarTests/CopilotAccountCreditEntitlementTests.swift @@ -0,0 +1,121 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct CopilotAccountCreditEntitlementTests { + @Test + func `account credit entitlement overrides the global fallback`() throws { + let settings = Self.makeSettingsStore() + settings.copilotSeatCreditEntitlementRaw = "3000" + settings.addTokenAccount(provider: .copilot, label: "Work", token: "token-1") + let account = try #require(settings.selectedTokenAccount(for: .copilot)) + + settings.updateTokenAccount( + provider: .copilot, + accountID: account.id, + seatCreditEntitlement: "1500") + + let snapshot = settings.copilotSettingsSnapshot(tokenOverride: nil) + #expect(snapshot.seatCreditEntitlement == 1500) + // The global values stay untouched as the fallback for accounts without an override. + #expect(settings.copilotSeatCreditEntitlementRaw == "3000") + } + + @Test + func `credit entitlements fall back to the global values when the account has none`() { + let settings = Self.makeSettingsStore() + settings.copilotSeatCreditEntitlementRaw = "3000" + settings.addTokenAccount(provider: .copilot, label: "Legacy", token: "token-1") + + let snapshot = settings.copilotSettingsSnapshot(tokenOverride: nil) + + #expect(snapshot.seatCreditEntitlement == 3000) + } + + @Test + func `credit entitlements follow the selected account`() throws { + let settings = Self.makeSettingsStore() + settings.copilotSeatCreditEntitlementRaw = "3000" + settings.addTokenAccount(provider: .copilot, label: "Personal", token: "token-1") + settings.addTokenAccount(provider: .copilot, label: "Work", token: "token-2") + let accounts = settings.tokenAccounts(for: .copilot) + let personal = try #require(accounts.first { $0.label == "Personal" }) + let work = try #require(accounts.first { $0.label == "Work" }) + settings.updateTokenAccount( + provider: .copilot, + accountID: personal.id, + seatCreditEntitlement: "300") + settings.updateTokenAccount( + provider: .copilot, + accountID: work.id, + seatCreditEntitlement: "4500") + + settings.setActiveTokenAccountIndex(0, for: .copilot) + #expect(settings.copilotSettingsSnapshot(tokenOverride: nil).seatCreditEntitlement == 300) + + settings.setActiveTokenAccountIndex(1, for: .copilot) + #expect(settings.copilotSettingsSnapshot(tokenOverride: nil).seatCreditEntitlement == 4500) + } + + @Test + func `account credit entitlements persist through the config store`() throws { + let suite = "CopilotAccountCreditEntitlementTests-persistence" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let first = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + + first.addTokenAccount(provider: .copilot, label: "Work", token: "token-1") + let account = try #require(first.selectedTokenAccount(for: .copilot)) + first.updateTokenAccount( + provider: .copilot, + accountID: account.id, + seatCreditEntitlement: "1500") + + let reloadedStore = testConfigStore(suiteName: suite, reset: false) + let second = Self.makeSettingsStore(userDefaults: defaults, configStore: reloadedStore) + let reloaded = try #require(second.selectedTokenAccount(for: .copilot)) + #expect(reloaded.seatCreditEntitlement == "1500") + let snapshot = second.copilotSettingsSnapshot(tokenOverride: nil) + #expect(snapshot.seatCreditEntitlement == 1500) + } + + private static func makeSettingsStore( + suiteName: String = "CopilotAccountCreditEntitlementTests") + -> SettingsStore + { + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + defaults.set(false, forKey: "debugDisableKeychainAccess") + return Self.makeSettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suiteName)) + } + + private static func makeSettingsStore( + userDefaults: UserDefaults, + configStore: CodexBarConfigStore) + -> SettingsStore + { + SettingsStore( + userDefaults: userDefaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + codexCookieStore: InMemoryCookieHeaderStore(), + claudeCookieStore: InMemoryCookieHeaderStore(), + cursorCookieStore: InMemoryCookieHeaderStore(), + opencodeCookieStore: InMemoryCookieHeaderStore(), + factoryCookieStore: InMemoryCookieHeaderStore(), + minimaxCookieStore: InMemoryMiniMaxCookieStore(), + minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), + kimiTokenStore: InMemoryKimiTokenStore(), + augmentCookieStore: InMemoryCookieHeaderStore(), + ampCookieStore: InMemoryCookieHeaderStore(), + copilotTokenStore: InMemoryCopilotTokenStore(), + tokenAccountStore: InMemoryTokenAccountStore(), + antigravityOAuthCredentialsStore: AntigravityOAuthCredentialsStore()) + } +} diff --git a/Tests/CodexBarTests/CopilotCreditsSettingsTests.swift b/Tests/CodexBarTests/CopilotCreditsSettingsTests.swift new file mode 100644 index 0000000000..5abee17298 --- /dev/null +++ b/Tests/CodexBarTests/CopilotCreditsSettingsTests.swift @@ -0,0 +1,72 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CopilotCreditsSettingsTests { + @Test + func `parses a plain entitlement`() { + #expect(CopilotCreditEntitlementParser.parse("3000") == 3000) + #expect(CopilotCreditEntitlementParser.parse(" 6000 ") == 6000) + #expect(CopilotCreditEntitlementParser.parse("1500.5") == 1500.5) + } + + @Test + func `rejects blank non numeric and non positive values`() { + #expect(CopilotCreditEntitlementParser.parse("") == nil) + #expect(CopilotCreditEntitlementParser.parse(" ") == nil) + #expect(CopilotCreditEntitlementParser.parse("abc") == nil) + #expect(CopilotCreditEntitlementParser.parse("0") == nil) + #expect(CopilotCreditEntitlementParser.parse("-100") == nil) + } + + @Test + func `rejects non finite values`() { + // "inf"/"1e999" parse to .infinity, which would crash progress validation downstream. + #expect(CopilotCreditEntitlementParser.parse("inf") == nil) + #expect(CopilotCreditEntitlementParser.parse("infinity") == nil) + #expect(CopilotCreditEntitlementParser.parse("1e999") == nil) + } + + @Test + func `settings snapshot carries credit configuration`() { + let settings = ProviderSettingsSnapshot.CopilotProviderSettings( + apiToken: "test-token-placeholder", + seatCreditEntitlement: 3000) + #expect(settings.seatCreditEntitlement == 3000) + } + + @Test + func `settings snapshot defaults credits off`() { + let settings = ProviderSettingsSnapshot.CopilotProviderSettings(apiToken: "t") + #expect(settings.seatCreditEntitlement == nil) + } + + @Test + func `token account decoding defaults missing entitlements to nil`() throws { + // Legacy accounts predate the per-account entitlement keys and must still decode. + let json = """ + { + "id": "\(UUID().uuidString)", + "label": "Legacy", + "token": "token", + "addedAt": 0 + } + """ + let account = try JSONDecoder().decode(ProviderTokenAccount.self, from: Data(json.utf8)) + #expect(account.seatCreditEntitlement == nil) + #expect(account.sanitizedSeatCreditEntitlement == nil) + } + + @Test + func `token account entitlements survive a codable round trip`() throws { + let account = ProviderTokenAccount( + id: UUID(), + label: "Work", + token: "token", + addedAt: 0, + lastUsed: nil, + seatCreditEntitlement: "1500") + let decoded = try JSONDecoder().decode(ProviderTokenAccount.self, from: JSONEncoder().encode(account)) + #expect(decoded.seatCreditEntitlement == "1500") + } +} diff --git a/Tests/CodexBarTests/CopilotMenuCardModelTests.swift b/Tests/CodexBarTests/CopilotMenuCardModelTests.swift index 235b7106e1..8bce98e386 100644 --- a/Tests/CodexBarTests/CopilotMenuCardModelTests.swift +++ b/Tests/CodexBarTests/CopilotMenuCardModelTests.swift @@ -108,6 +108,104 @@ struct CopilotMenuCardModelTests { #expect(premium.pacePercent == nil) } + private func makeModel( + details: [ProviderDetailSection], + showOptionalUsage: Bool = true, + now: Date = Date(timeIntervalSince1970: 0)) throws -> UsageMenuCardView.Model + { + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + details: details, + updatedAt: now) + let metadata = try #require(ProviderDefaults.metadata[.copilot]) + return UsageMenuCardView.Model.make(.init( + provider: .copilot, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: showOptionalUsage, + hidePersonalInfo: false, + now: now)) + } + + @Test + func `credit lane keeps its bar data when an entitlement is set`() throws { + let model = try self.makeModel(details: [ + ProviderDetailSection(title: CopilotCreditDetailRows.sectionTitle, rows: [ + Self.makeCreditRow( + id: CopilotCreditDetailRows.seatRowID, + value: "31 / 3000", + progress: ProviderDetailSection.Row.Progress(used: 31, total: 3000)), + ]), + ]) + let row = try #require(model.providerDetails.lazy.flatMap(\.rows) + .first { $0.id == CopilotCreditDetailRows.seatRowID }) + #expect(row.label == "Credits used") + #expect(row.value == "31 / 3000") + #expect(row.progress?.used == 31) + #expect(row.progress?.total == 3000) + } + + @Test + func `credit lane stays text only when no entitlement is set`() throws { + let model = try self.makeModel(details: [ + ProviderDetailSection(title: CopilotCreditDetailRows.sectionTitle, rows: [ + Self.makeCreditRow( + id: CopilotCreditDetailRows.seatRowID, + value: "31"), + ]), + ]) + let row = try #require(model.providerDetails.lazy.flatMap(\.rows) + .first { $0.id == CopilotCreditDetailRows.seatRowID }) + #expect(row.value == "31") + #expect(row.progress == nil) + } + + @Test + func `credit rows are absent without credit data`() throws { + let model = try self.makeModel(details: []) + #expect(model.providerDetails.flatMap(\.rows).contains { $0.id?.hasSuffix("-credits") == true } == false) + } + + @Test + func `credit rows render even when optional extra usage is disabled`() throws { + // Regression guard: Copilot credit rows are core data (GitHub bills some accounts by + // credit, not by rate window) and must not be swallowed by the optional-usage policy + // that gates optional detail sections. + let model = try self.makeModel( + details: [ + ProviderDetailSection(title: CopilotCreditDetailRows.sectionTitle, rows: [ + Self.makeCreditRow( + id: CopilotCreditDetailRows.seatRowID, + value: "31 / 3000", + progress: ProviderDetailSection.Row.Progress(used: 31, total: 3000)), + ]), + ], + showOptionalUsage: false) + #expect(model.providerDetails.flatMap(\.rows).contains { $0.id == CopilotCreditDetailRows.seatRowID }) + } + + private static func makeCreditRow( + id: String, + label: String = "Credits used", + value: String, + progress: ProviderDetailSection.Row.Progress? = nil) throws -> ProviderDetailSection.Row + { + try ProviderDetailSection.Row(id: id, label: label, value: value, progress: progress) + } + private static func model(snapshot: UsageSnapshot, now: Date) throws -> UsageMenuCardView.Model { let metadata = try #require(ProviderDefaults.metadata[.copilot]) return UsageMenuCardView.Model.make(.init( diff --git a/Tests/CodexBarTests/CopilotUsageFetcherTests.swift b/Tests/CodexBarTests/CopilotUsageFetcherTests.swift index 3bc02b4542..715a2efcde 100644 --- a/Tests/CodexBarTests/CopilotUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CopilotUsageFetcherTests.swift @@ -369,6 +369,254 @@ struct CopilotUsageFetcherTests { #expect(window?.resetsAt == resetDate) } + @Test + func `fetch renders a credit bar when a seat entitlement is configured`() async throws { + let transport = ProviderHTTPTransportStub { request in + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + let data = Data( + """ + { + "copilot_plan": "business", + "token_based_billing": true, + "quota_reset_date": "2026-09-01", + "quota_snapshots": { + "premium_interactions": { + "entitlement": 0, "remaining": 0, "percent_remaining": 100, + "quota_id": "premium_interactions", "unlimited": true, "credits_used": 31 + } + } + } + """.utf8) + return (data, response) + } + + let usage = try await CopilotUsageFetcher( + token: "test-token-placeholder", + seatEntitlement: 3000, + transport: transport) + .fetch() + + let row = try #require(usage.detailRow(id: CopilotCreditDetailRows.seatRowID)) + #expect(row.label == "Credits used") + #expect(row.value == "31 / 3000") + #expect(row.progress?.used == 31) + #expect(row.progress?.total == 3000) + #expect(row.usageValue == 31) + #expect(row.secondaryValue != nil) + // Regression guard for #1258: credits must not resurrect a fake quota bar. + #expect(usage.primary == nil) + #expect(usage.secondary == nil) + } + + @Test + func `fetch renders seat credits as text when no entitlement is configured`() async throws { + // Isolates the `tokenBasedBilling` disjunct in the seat-row gate: credits_used is zero, no + // snapshot carries `unlimited`, and no seatEntitlement is configured, so tokenBasedBilling is + // the only thing that can make the gate true. A Business account reporting zero credits early + // in the billing month is a real, common state, and the seat row must still appear for it -- + // that is this gate's entire purpose. + let transport = ProviderHTTPTransportStub { request in + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + let data = Data( + """ + { + "copilot_plan": "business", + "token_based_billing": true, + "quota_snapshots": { + "premium_interactions": { + "entitlement": 0, "remaining": 0, "percent_remaining": 100, + "quota_id": "premium_interactions", "credits_used": 0 + } + } + } + """.utf8) + return (data, response) + } + + let usage = try await CopilotUsageFetcher( + token: "test-token-placeholder", + transport: transport) + .fetch() + + let row = try #require(usage.detailRow(id: CopilotCreditDetailRows.seatRowID)) + #expect(row.value == "0") + #expect(row.progress == nil) + // Text-only rows still carry the numeric usage so a later entitlement edit can grow + // the bar from the cached snapshot without a refresh. + #expect(row.usageValue == 0) + #expect(usage.primary == nil) + #expect(usage.secondary == nil) + } + + @Test + func `fetch omits seat credits for metered accounts reporting zero credits`() async throws { + // Regression guard: `credits_used: 0` on a NOT credit-billed snapshot must not grow a + // permanent, unremovable "0 credits used" row on Copilot Pro/Individual seats. + let transport = ProviderHTTPTransportStub { request in + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + let data = Data( + """ + { + "copilot_plan": "business", + "token_based_billing": false, + "quota_snapshots": { + "premium_interactions": { + "entitlement": 300, "remaining": 300, "percent_remaining": 100, + "quota_id": "premium_interactions", "credits_used": 0 + } + } + } + """.utf8) + return (data, response) + } + + let usage = try await CopilotUsageFetcher( + token: "test-token-placeholder", + transport: transport) + .fetch() + + #expect(usage.detailRow(id: CopilotCreditDetailRows.seatRowID) == nil) + // The metered bar itself is unaffected by the credits suppression. + #expect(usage.primary?.usedPercent == 0) + } + + @Test + func `fetch retains seat credits for a metered account with positive credits`() async throws { + let transport = ProviderHTTPTransportStub { request in + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + let data = Data( + """ + { + "copilot_plan": "business", + "token_based_billing": false, + "quota_snapshots": { + "premium_interactions": { + "entitlement": 300, "remaining": 275, "percent_remaining": 91.67, + "quota_id": "premium_interactions", "credits_used": 25 + } + } + } + """.utf8) + return (data, response) + } + + let usage = try await CopilotUsageFetcher( + token: "test-token-placeholder", + transport: transport) + .fetch() + + #expect(usage.detailRow(id: CopilotCreditDetailRows.seatRowID)?.value == "25") + #expect(usage.primary != nil) + } + + @Test + func `fetch retains seat credits for a metered account with a configured entitlement`() async throws { + let transport = ProviderHTTPTransportStub { request in + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + let data = Data( + """ + { + "copilot_plan": "business", + "token_based_billing": false, + "quota_snapshots": { + "premium_interactions": { + "entitlement": 300, "remaining": 300, "percent_remaining": 100, + "quota_id": "premium_interactions", "credits_used": 0 + } + } + } + """.utf8) + return (data, response) + } + + let usage = try await CopilotUsageFetcher( + token: "test-token-placeholder", + seatEntitlement: 3000, + transport: transport) + .fetch() + + let row = try #require(usage.detailRow(id: CopilotCreditDetailRows.seatRowID)) + #expect(row.value == "0 / 3000") + #expect(row.progress?.total == 3000) + } + + @Test + func `fetch omits credits when the payload has none`() async throws { + let transport = ProviderHTTPTransportStub { request in + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + let data = Data( + """ + { + "copilot_plan": "individual", + "quota_snapshots": { + "premium_interactions": { + "entitlement": 300, "remaining": 150, "percent_remaining": 50, + "quota_id": "premium_interactions" + } + } + } + """.utf8) + return (data, response) + } + + let usage = try await CopilotUsageFetcher( + token: "test-token-placeholder", + transport: transport) + .fetch() + + #expect(usage.detailRow(id: CopilotCreditDetailRows.seatRowID) == nil) + // Metered accounts keep their real bar. + #expect(usage.primary?.usedPercent == 50) + } + + @Test + func `makeCreditDetails suppresses metered zero credit accounts without an entitlement`() { + let details = CopilotUsageFetcher.makeCreditDetails( + creditsUsed: 0, + seatEntitlement: nil, + tokenBasedBilling: false, + hasUnlimitedQuota: false, + resetsAt: nil) + + #expect(details.isEmpty) + } + + @Test + func `makeCreditDetails retains credits when the account has unlimited quota`() { + let details = CopilotUsageFetcher.makeCreditDetails( + creditsUsed: 0, + seatEntitlement: nil, + tokenBasedBilling: false, + hasUnlimitedQuota: true, + resetsAt: nil) + + #expect(details.first?.rows.first?.id == CopilotCreditDetailRows.seatRowID) + } + @Test func `parseQuotaResetDate supports date only and ISO timestamps`() throws { let dateOnly = try #require(ISO8601DateFormatter().date(from: "2026-07-01T00:00:00Z")) diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 2c063c77e1..a9bb8c1533 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1503,7 +1503,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This tagged diagnostic payload encodes MiniMax details under the matching wire key."), SuppressedProviderReference( path: "Sources/CodexBarCore/UsageFetcher.swift", - line: 1486, + line: 1494, anchor: "providerID: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), @@ -1942,7 +1942,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "The sub2api menu card localizes and groups provider-owned usage detail rows for display."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView.swift", - line: 1107, + line: 1109, anchor: "if provider == .kiro,", expectedProviderIDs: ["kilo", "kiro"], expectedReferenceCount: 2, @@ -1950,7 +1950,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView.swift", - line: 1130, + line: 1132, anchor: "if provider == .minimax {", expectedProviderIDs: ["codex", "minimax"], expectedReferenceCount: 2, @@ -1958,7 +1958,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView.swift", - line: 1165, + line: 1167, anchor: "guard let loginMethod = snapshot?.loginMethod(for: .kilo) else {", expectedProviderIDs: ["kilo"], expectedReferenceCount: 1, @@ -1966,7 +1966,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView.swift", - line: 1249, + line: 1251, anchor: "if input.provider == .antigravity {", expectedProviderIDs: ["antigravity", "mistral"], expectedReferenceCount: 2, @@ -1974,7 +1974,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView.swift", - line: 1269, + line: 1271, anchor: "if input.provider == .codex, let codexProjection = input.codexProjection {", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -1982,7 +1982,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView.swift", - line: 1285, + line: 1287, anchor: "if input.provider != .codex, let weekly = snapshot.secondary {", expectedProviderIDs: ["alibaba", "alibabatokenplan", "codex", "perplexity", "sub2api"], expectedReferenceCount: 5, @@ -1996,7 +1996,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView.swift", - line: 1324, + line: 1326, anchor: "if input.provider == .kilo || input.provider == .kimi,", expectedProviderIDs: ["kilo", "kimi"], expectedReferenceCount: 2, @@ -2004,7 +2004,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView.swift", - line: 1420, + line: 1422, anchor: "var paceDetail = if input.provider == .kimi {", expectedProviderIDs: ["kimi"], expectedReferenceCount: 1, @@ -2012,7 +2012,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView.swift", - line: 1436, + line: 1438, anchor: "if input.provider == .warp,", expectedProviderIDs: ["chutes", "kilo", "kiro", "litellm", "sub2api", "warp"], expectedReferenceCount: 6, @@ -2020,7 +2020,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView.swift", - line: 1469, + line: 1471, anchor: "if input.provider == .alibaba || input.provider == .alibabatokenplan,", expectedProviderIDs: ["alibaba", "alibabatokenplan", "copilot", "crof", "manus", "perplexity", "zenmux"], expectedReferenceCount: 8, @@ -2037,7 +2037,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView.swift", - line: 1510, + line: 1512, anchor: "if input.provider == .synthetic,", expectedProviderIDs: ["synthetic"], expectedReferenceCount: 1, @@ -2281,7 +2281,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/SettingsStore.swift", - line: 1027, + line: 1030, anchor: "if !seen.contains(.factory), let zaiIndex = ordered.firstIndex(of: .zai) {", expectedProviderIDs: ["factory", "minimax", "zai"], expectedReferenceCount: 8, diff --git a/Tests/CodexBarTests/ProviderDetailSectionTests.swift b/Tests/CodexBarTests/ProviderDetailSectionTests.swift index 4cc8cbe39c..8f73953f0d 100644 --- a/Tests/CodexBarTests/ProviderDetailSectionTests.swift +++ b/Tests/CodexBarTests/ProviderDetailSectionTests.swift @@ -67,6 +67,63 @@ struct ProviderDetailSectionTests { } } + @Test + func `row progress divides used by total without clamping`() throws { + let progress = try ProviderDetailSection.Row.Progress(used: 31, total: 3000) + #expect(abs(progress.usedPercent - 1.0333333) < 0.0001) + #expect(try ProviderDetailSection.Row.Progress(used: 150, total: 100).usedPercent == 150) + } + + @Test + func `row progress rejects non positive totals and nonfinite values`() throws { + #expect(throws: ProviderDetailSection.ValidationError.self) { + _ = try ProviderDetailSection.Row.Progress(used: 31, total: 0) + } + #expect(throws: ProviderDetailSection.ValidationError.self) { + _ = try ProviderDetailSection.Row.Progress(used: 31, total: -5) + } + #expect(throws: ProviderDetailSection.ValidationError.self) { + _ = try ProviderDetailSection.Row.Progress(used: .infinity, total: 100) + } + } + + @Test + func `row id progress and usage value round trip through codable`() throws { + let row = try ProviderDetailSection.Row( + id: "copilot-seat-credits", + label: "Credits used", + value: "81 / 6000", + progress: ProviderDetailSection.Row.Progress(used: 81, total: 6000), + usageValue: 81) + + let data = try JSONEncoder().encode(row) + let decoded = try JSONDecoder().decode(ProviderDetailSection.Row.self, from: data) + + #expect(decoded == row) + } + + @Test + func `row usage value rejects nonfinite values`() throws { + #expect(throws: ProviderDetailSection.ValidationError.self) { + _ = try ProviderDetailSection.Row(label: "Credits used", value: "31", usageValue: .infinity) + } + #expect(throws: ProviderDetailSection.ValidationError.self) { + _ = try ProviderDetailSection.Row(label: "Credits used", value: "31", usageValue: .nan) + } + } + + @Test + func `row without id and progress decodes from legacy payloads`() throws { + let json = #"{"label":"Credits used","value":"31","secondaryValue":"Resets Sep 1"}"# + + let row = try JSONDecoder().decode(ProviderDetailSection.Row.self, from: Data(json.utf8)) + + #expect(row.id == nil) + #expect(row.progress == nil) + #expect(row.usageValue == nil) + #expect(row.label == "Credits used") + } + @Test func `current snapshot fixture decodes with empty details`() throws { let url = try #require(Bundle.module.url( diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index be57f16545..656821bc3d 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -478,6 +478,39 @@ struct ProviderSettingsDescriptorTests { #expect(field.actions.map(\.id) == ["refresh-copilot-budget-cookie"]) } + @Test + func `copilot seat credit entitlement field writes through to the settings snapshot`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-copilot-seat-entitlement") + let context = fixture.settingsContext(provider: .copilot) + + let fields = CopilotProviderImplementation().settingsFields(context: context) + let field = try #require(fields.first { $0.id == "copilot-seat-credit-entitlement" }) + field.binding.wrappedValue = "3000" + + #expect(fixture.settings.copilotSeatCreditEntitlementRaw == "3000") + #expect(fixture.settings.copilotSettingsSnapshot(tokenOverride: nil).seatCreditEntitlement == 3000) + } + + @Test + func `copilot seat credit entitlement field writes to the selected account`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-copilot-seat-account") + fixture.settings.copilotSeatCreditEntitlementRaw = "3000" + fixture.settings.addTokenAccount(provider: .copilot, label: "Work", token: "token-1") + let context = fixture.settingsContext(provider: .copilot) + + let fields = CopilotProviderImplementation().settingsFields(context: context) + let field = try #require(fields.first { $0.id == "copilot-seat-credit-entitlement" }) + // The field surfaces the global fallback until the account sets its own value. + #expect(field.binding.wrappedValue == "3000") + + field.binding.wrappedValue = "1500" + + let account = try #require(fixture.settings.selectedTokenAccount(for: .copilot)) + #expect(account.seatCreditEntitlement == "1500") + #expect(fixture.settings.copilotSeatCreditEntitlementRaw == "3000") + #expect(fixture.settings.copilotSettingsSnapshot(tokenOverride: nil).seatCreditEntitlement == 1500) + } + @Test func `kimi exposes usage source picker plus api and cookie fields`() throws { let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-kimi") diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index 00898f0272..0d6eb25e77 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -355,6 +355,136 @@ struct UsageStoreCoverageTests { #expect(store.lastKnownResetSnapshots[.copilot]?.primary?.usedPercent == 10) } + @Test + func `updating copilot seat entitlement syncs row and reset baseline`() throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-copilot-seat-update") + let store = Self.makeUsageStore(settings: settings) + store._setSnapshotForTesting( + Self.makeCopilotSeatCreditsSnapshot(used: 31, entitlement: 3000), + provider: .copilot) + store.lastKnownResetSnapshots[.copilot] = Self.makeCopilotSeatCreditsSnapshot(used: 31, entitlement: 1500) + + store.updateCopilotSeatCreditEntitlement(6000) + + let liveRow = try #require(store.snapshot(for: .copilot)?.detailRow(id: CopilotCreditDetailRows.seatRowID)) + #expect(liveRow.value == "31 / 6000") + #expect(liveRow.progress?.used == 31) + #expect(liveRow.progress?.total == 6000) + let resetRow = try #require( + store.lastKnownResetSnapshots[.copilot]?.detailRow(id: CopilotCreditDetailRows.seatRowID)) + #expect(resetRow.value == "31 / 6000") + #expect(resetRow.progress?.total == 6000) + } + + @Test + func `clearing copilot seat entitlement strips denominator and progress`() throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-copilot-seat-clear") + let store = Self.makeUsageStore(settings: settings) + store._setSnapshotForTesting( + Self.makeCopilotSeatCreditsSnapshot(used: 31, entitlement: 3000), + provider: .copilot) + store.lastKnownResetSnapshots[.copilot] = Self.makeCopilotSeatCreditsSnapshot(used: 31, entitlement: 3000) + + store.updateCopilotSeatCreditEntitlement(nil) + + let liveRow = try #require(store.snapshot(for: .copilot)?.detailRow(id: CopilotCreditDetailRows.seatRowID)) + #expect(liveRow.value == "31") + #expect(liveRow.progress == nil) + let resetRow = try #require( + store.lastKnownResetSnapshots[.copilot]?.detailRow(id: CopilotCreditDetailRows.seatRowID)) + #expect(resetRow.value == "31") + #expect(resetRow.progress == nil) + } + + @Test + func `copilot seat entitlement update is a no-op without a seat row`() { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-copilot-seat-missing") + let store = Self.makeUsageStore(settings: settings) + let live = Self.makeCopilotSnapshot(usedPercent: 20, extraRateWindows: nil) + let resetBaseline = Self.makeCopilotSnapshot(usedPercent: 10, extraRateWindows: nil) + store._setSnapshotForTesting(live, provider: .copilot) + store.lastKnownResetSnapshots[.copilot] = resetBaseline + + store.updateCopilotSeatCreditEntitlement(6000) + store.updateCopilotSeatCreditEntitlement(nil) + + #expect(store.snapshot(for: .copilot)?.details == live.details) + #expect(store.snapshot(for: .copilot)?.primary?.usedPercent == 20) + #expect(store.lastKnownResetSnapshots[.copilot]?.details == resetBaseline.details) + #expect(store.lastKnownResetSnapshots[.copilot]?.primary?.usedPercent == 10) + } + + @Test + func `entering copilot seat entitlement turns a text-only row into a bar`() throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-copilot-seat-text") + let store = Self.makeUsageStore(settings: settings) + let live = Self.makeCopilotSeatCreditsSnapshot(used: 31, entitlement: nil) + let resetBaseline = Self.makeCopilotSeatCreditsSnapshot(used: 31, entitlement: nil) + store._setSnapshotForTesting(live, provider: .copilot) + store.lastKnownResetSnapshots[.copilot] = resetBaseline + + store.updateCopilotSeatCreditEntitlement(6000) + + let liveRow = try #require(store.snapshot(for: .copilot)?.detailRow(id: CopilotCreditDetailRows.seatRowID)) + #expect(liveRow.value == "31 / 6000") + #expect(liveRow.progress?.used == 31) + #expect(liveRow.progress?.total == 6000) + #expect(liveRow.usageValue == 31) + let resetRow = try #require( + store.lastKnownResetSnapshots[.copilot]?.detailRow(id: CopilotCreditDetailRows.seatRowID)) + #expect(resetRow.value == "31 / 6000") + #expect(resetRow.progress?.total == 6000) + + store.updateCopilotSeatCreditEntitlement(nil) + + #expect(store.snapshot(for: .copilot)?.details == live.details) + #expect(store.lastKnownResetSnapshots[.copilot]?.details == resetBaseline.details) + } + + @Test + func `copilot seat entitlement update is a no-op when the seat row has no numeric usage`() { + // Legacy cached rows predate `usageValue`: with neither a progress ratio nor a retained + // numeric usage there is nothing to rebuild from, so the row waits for the next refresh. + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-copilot-seat-legacy") + let store = Self.makeUsageStore(settings: settings) + let row = ProviderDetailSection.Row.makeRow( + id: CopilotCreditDetailRows.seatRowID, + label: "Credits used", + value: "31", + secondaryValue: "resets Jul 1") + let details = [ProviderDetailSection.makeSection(title: CopilotCreditDetailRows.sectionTitle, rows: [row])] + let live = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + details: details, + updatedAt: Date(timeIntervalSince1970: 1_780_358_400)) + store._setSnapshotForTesting(live, provider: .copilot) + store.lastKnownResetSnapshots[.copilot] = live + + store.updateCopilotSeatCreditEntitlement(6000) + store.updateCopilotSeatCreditEntitlement(nil) + + #expect(store.snapshot(for: .copilot)?.details == live.details) + #expect(store.lastKnownResetSnapshots[.copilot]?.details == live.details) + } + + @Test + func `copilot seat entitlement update syncs stale baseline when live snapshot has no seat row`() throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-copilot-seat-baseline") + let store = Self.makeUsageStore(settings: settings) + let live = Self.makeCopilotSnapshot(usedPercent: 20, extraRateWindows: nil) + store._setSnapshotForTesting(live, provider: .copilot) + store.lastKnownResetSnapshots[.copilot] = Self.makeCopilotSeatCreditsSnapshot(used: 31, entitlement: 1500) + + store.updateCopilotSeatCreditEntitlement(6000) + + #expect(store.snapshot(for: .copilot)?.details == live.details) + let resetRow = try #require( + store.lastKnownResetSnapshots[.copilot]?.detailRow(id: CopilotCreditDetailRows.seatRowID)) + #expect(resetRow.value == "31 / 6000") + #expect(resetRow.progress?.total == 6000) + } + @Test func `permission prompt errors are detected for notifications`() { let errors: [LocalizedTestError] = [ @@ -1133,6 +1263,31 @@ extension UsageStoreCoverageTests { window: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil)) } + private static func makeCopilotSeatCreditsSnapshot(used: Double, entitlement: Double?) -> UsageSnapshot { + let usedLabel = UsageFormatter.creditsNumberString(from: used) + let row: ProviderDetailSection.Row = if let entitlement { + .makeRow( + id: CopilotCreditDetailRows.seatRowID, + label: "Credits used", + value: "\(usedLabel) / \(UsageFormatter.creditsNumberString(from: entitlement))", + secondaryValue: "resets Jul 1", + progress: .makeProgress(used: used, total: entitlement), + usageValue: used) + } else { + .makeRow( + id: CopilotCreditDetailRows.seatRowID, + label: "Credits used", + value: usedLabel, + secondaryValue: "resets Jul 1", + usageValue: used) + } + return UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + details: [.makeSection(title: CopilotCreditDetailRows.sectionTitle, rows: [row])], + updatedAt: Date(timeIntervalSince1970: 1_780_358_400)) + } + private static func enableOnly(_ enabledProvider: UsageProvider, settings: SettingsStore) throws { let metadata = ProviderRegistry.shared.metadata for provider in UsageProvider.allCases { diff --git a/docs/copilot.md b/docs/copilot.md index 9643cc234f..49ddbe3e09 100644 --- a/docs/copilot.md +++ b/docs/copilot.md @@ -59,11 +59,29 @@ Copilot uses GitHub OAuth device flow and the Copilot internal usage API for pri is enabled. - Product budget: `copilot` - SKU budgets: `copilot_premium_request`, `copilot_agent_premium_request`, `spark_premium_request` +- Seat AI credits: `quota_snapshots.premium_interactions.credits_used` → the shared "Credits used" provider-detail + row, shown only when it carries real signal (token-based billing, unlimited quota, nonzero credits, or a + configured seat entitlement) so a metered Pro/Individual seat never grows a permanent "0 credits used" row. + Deliberately not summed with `chat`/`completions` credits — GitHub can report the same pool under multiple + snapshot keys, and summing would double-count. - Reset dates are not provided by the API. - Plan label from `copilotPlan`. +## AI credit entitlements +GitHub does not publish an included-credit entitlement on any documented endpoint — all 8 billing endpoints plus +`budgets`, `cost-centers`, and `usage/summary` were probed, and none returns a ceiling for the seat. The denominator +is therefore user-entered: +- Preferences → Providers → Copilot → "Included AI credits (per seat)" + +A configured entitlement turns the row into a progress bar ("31 / 3000") via the shared provider-detail row's +optional progress ratio. Without one, the row stays plain text, because a bar would imply a limit CodexBar cannot +actually know. Either way the row also carries the numeric credits used (`usageValue`), so editing or clearing +the entitlement rewrites the cached row (text ↔ bar) immediately, even when the follow-up refresh never lands +(offline, token lost, 401). + ## Key files - `Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift` - `Sources/CodexBarCore/Providers/Copilot/CopilotDeviceFlow.swift` +- `Sources/CodexBarCore/Providers/Copilot/CopilotCreditEntitlementParser.swift` - `Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift` - `Sources/CodexBar/CopilotTokenStore.swift` (legacy migration helper)