From 443f76625a51879b89ad236ea3dd99e280070f27 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 3 Aug 2026 15:24:00 -0700 Subject: [PATCH] fix: parse Command Code usage windows Co-authored-by: Derek Zeng --- CHANGELOG.md | 1 + .../CodexBar/UsageStore+PlanUtilization.swift | 6 +- .../CodexBar/UsageStore+QuotaWarnings.swift | 1 - Sources/CodexBar/UsageStore+Refresh.swift | 12 +-- .../UsageStore+SessionQuotaTransition.swift | 11 --- .../CommandCodeProviderDescriptor.swift | 11 ++- .../CommandCode/CommandCodeUsageFetcher.swift | 37 +++++++- .../CommandCodeUsageSnapshot.swift | 24 +++-- Sources/CodexBarCore/UsageFetcher.swift | 4 + .../CommandCodeProviderTests.swift | 5 + .../CommandCodeQuotaTransitionTests.swift | 93 ++++++++++++++----- .../CommandCodeUsageFetcherTests.swift | 58 +++++++++++- .../CommandCode/window-limits-nested.json | 20 ++++ .../CommandCode/window-limits-root.json | 20 ++++ .../ProviderPaceCapabilityTests.swift | 4 +- ...StorePlanUtilizationCelebrationTests.swift | 16 ++-- docs/command-code.md | 10 +- docs/providers.md | 2 +- 18 files changed, 256 insertions(+), 79 deletions(-) create mode 100644 Tests/CodexBarTests/Fixtures/Providers/CommandCode/window-limits-nested.json create mode 100644 Tests/CodexBarTests/Fixtures/Providers/CommandCode/window-limits-root.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 92f03c6f5d..96d28a3c10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Menu: move each usage window's used percentage and reset time into its title row, with all pace detail on one line (#2182). Thanks @jack24254029! ### Fixed +- Command Code: parse and display 5-hour and weekly rolling limits alongside monthly credits and reset times (#2466). Thanks @derekszen! - ZoomMate: preserve browser cookie scope so parent-domain sessions reach both API hosts without leaking host-only cookies (fixes #2507). Thanks @weddle! - Sync: propagate provider configuration edits made by the CLI or directly in `config.json` to the iCloud fleet without echoing remotely applied writes. diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 00128c72ac..a5eec283fb 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -475,11 +475,7 @@ extension UsageStore { context: LimitResetDetectionContext, samples: [PlanUtilizationSeriesSample]) { - let shouldIgnoreCommandCode = context.provider == .commandcode - && context.snapshot.commandCodeSubscriptionEnrichmentUnavailable - let sessionObservation: LimitResetObservation? = if shouldIgnoreCommandCode { - nil - } else if context.provider == .codex { + let sessionObservation: LimitResetObservation? = if context.provider == .codex { samples.last(where: { $0.name == .session }).map { LimitResetObservation( usedPercent: $0.entry.usedPercent, diff --git a/Sources/CodexBar/UsageStore+QuotaWarnings.swift b/Sources/CodexBar/UsageStore+QuotaWarnings.swift index fbb55e25cc..d89ba9598d 100644 --- a/Sources/CodexBar/UsageStore+QuotaWarnings.swift +++ b/Sources/CodexBar/UsageStore+QuotaWarnings.swift @@ -56,7 +56,6 @@ extension UsageStore { self.clearQuotaLowHookUsage(provider: provider) } guard notificationsEnabled || hooksActive else { return } - if provider == .commandcode, snapshot.commandCodeSubscriptionEnrichmentUnavailable { return } let accountContext = QuotaWarningAccountContext( discriminator: accountDiscriminator, diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index 0343a4c8e8..50ebf64a5d 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -98,20 +98,20 @@ extension UsageStore { let previousProvesPaidDepletion = previous?.commandCodeHasSubscriptionPlan == true || (previous?.commandCodeSubscriptionEnrichmentUnavailable == true && previous?.commandCodeMonthlyGrantDepleted == true && - previous?.primary?.usedPercent == 100) + previous?.tertiary?.usedPercent == 100) guard current.commandCodeSubscriptionEnrichmentUnavailable, current.commandCodeMonthlyGrantDepleted, previousProvesPaidDepletion, - let previousPrimary = previous?.primary + let previousMonthly = previous?.tertiary else { return current } let depleted = RateWindow( usedPercent: 100, - windowMinutes: previousPrimary.windowMinutes, - resetsAt: previousPrimary.resetsAt, - resetDescription: previousPrimary.resetDescription) - return current.with(primary: depleted, secondary: current.secondary) + windowMinutes: previousMonthly.windowMinutes, + resetsAt: previousMonthly.resetsAt, + resetDescription: previousMonthly.resetDescription) + return current.with(tertiary: depleted) } func refreshForSettingsChange() async { diff --git a/Sources/CodexBar/UsageStore+SessionQuotaTransition.swift b/Sources/CodexBar/UsageStore+SessionQuotaTransition.swift index 77d0d5bb5c..01b6ea7dd5 100644 --- a/Sources/CodexBar/UsageStore+SessionQuotaTransition.swift +++ b/Sources/CodexBar/UsageStore+SessionQuotaTransition.swift @@ -11,14 +11,6 @@ extension UsageStore { { // 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. - // Command Code synthesizes a depleted primary while subscription enrichment is unavailable. - // Preserve the prior notification state for that placeholder, but accept positive credit data. - if provider == .commandcode, - snapshot.commandCodeSubscriptionEnrichmentUnavailable, - SessionQuotaNotificationLogic.isDepleted(snapshot.primary?.remainingPercent) - { - return - } // Hooks have their own enable switch, so a configured quota_reached hook must fire on a // real depletion even when session quota notifications are off. Run transition detection // whenever notifications OR a matching hook rule is active; gate the OS notification post @@ -37,9 +29,6 @@ extension UsageStore { return } guard let sessionWindow = self.sessionQuotaWindow(provider: provider, snapshot: snapshot) else { - if provider == .commandcode, snapshot.commandCodeSubscriptionEnrichmentUnavailable { - return - } if provider == .codex { if let previous = self.sessionQuotaTransitionStates[.codex] { if previous.codexOwnerKey != codexOwnerKey { diff --git a/Sources/CodexBarCore/Providers/CommandCode/CommandCodeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/CommandCode/CommandCodeProviderDescriptor.swift index 3cb7eec5dc..0481bd85f9 100644 --- a/Sources/CodexBarCore/Providers/CommandCode/CommandCodeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/CommandCode/CommandCodeProviderDescriptor.swift @@ -9,12 +9,12 @@ public enum CommandCodeProviderDescriptor { metadata: ProviderMetadata( id: .commandcode, displayName: "Command Code", - sessionLabel: "Monthly credits", - weeklyLabel: "Monthly", - opusLabel: nil, - supportsOpus: false, + sessionLabel: "5-hour", + weeklyLabel: "Weekly", + opusLabel: "Monthly", + supportsOpus: true, supportsCredits: true, - creditsHint: "Monthly USD credits from Command Code billing.", + creditsHint: "Monthly USD credits and rolling usage limits from Command Code billing.", toggleTitle: "Show Command Code usage", cliName: "commandcode", defaultEnabled: false, @@ -38,6 +38,7 @@ public enum CommandCodeProviderDescriptor { tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Command Code cost summary is not yet supported." }), + pace: .calendarMonthResetWindow, fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .web], pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [CommandCodeWebFetchStrategy()] })), diff --git a/Sources/CodexBarCore/Providers/CommandCode/CommandCodeUsageFetcher.swift b/Sources/CodexBarCore/Providers/CommandCode/CommandCodeUsageFetcher.swift index 74b8d92763..43f36285f8 100644 --- a/Sources/CodexBarCore/Providers/CommandCode/CommandCodeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/CommandCode/CommandCodeUsageFetcher.swift @@ -69,6 +69,8 @@ public enum CommandCodeUsageFetcher { purchasedCredits: credits.purchasedCredits, premiumMonthlyCredits: credits.premiumMonthlyCredits, opensourceMonthlyCredits: credits.opensourceMonthlyCredits, + fiveHourWindow: credits.fiveHourWindow, + weeklyWindow: credits.weeklyWindow, plan: plan, billingPeriodEnd: subscription?.currentPeriodEnd, subscriptionStatus: subscription?.status, @@ -126,6 +128,8 @@ public enum CommandCodeUsageFetcher { let purchasedCredits: Double let premiumMonthlyCredits: Double let opensourceMonthlyCredits: Double + let fiveHourWindow: RateWindow? + let weeklyWindow: RateWindow? } struct SubscriptionPayload { @@ -199,11 +203,19 @@ public enum CommandCodeUsageFetcher { guard let monthly = self.double(from: credits["monthlyCredits"]) else { throw CommandCodeUsageError.parseFailed("Credits: missing monthlyCredits") } + let windowLimits = (root["windowLimits"] as? [String: Any]) + ?? (credits["windowLimits"] as? [String: Any]) return CreditsPayload( monthlyCredits: monthly, purchasedCredits: self.double(from: credits["purchasedCredits"]) ?? 0, premiumMonthlyCredits: self.double(from: credits["premiumMonthlyCredits"]) ?? 0, - opensourceMonthlyCredits: self.double(from: credits["opensourceMonthlyCredits"]) ?? 0) + opensourceMonthlyCredits: self.double(from: credits["opensourceMonthlyCredits"]) ?? 0, + fiveHourWindow: self.rateWindow( + from: windowLimits?["fiveHour"], + windowMinutes: 5 * 60), + weeklyWindow: self.rateWindow( + from: windowLimits?["weekly"], + windowMinutes: 7 * 24 * 60)) } static func parseSubscription(data: Data) throws -> SubscriptionPayload? { @@ -234,6 +246,21 @@ public enum CommandCodeUsageFetcher { return SubscriptionPayload(planID: planID, status: status, currentPeriodEnd: periodEnd) } + private static func rateWindow(from value: Any?, windowMinutes: Int) -> RateWindow? { + guard let limit = value as? [String: Any], + let cap = self.double(from: limit["cap"]), + cap > 0 + else { + return nil + } + let used = self.double(from: limit["used"]) ?? 0 + return RateWindow( + usedPercent: UsagePercent(used: used, limit: cap).displayClamped, + windowMinutes: windowMinutes, + resetsAt: self.date(from: limit["resetAt"]), + resetDescription: nil) + } + // MARK: - Value coercion private static func double(from value: Any?) -> Double? { @@ -250,12 +277,18 @@ public enum CommandCodeUsageFetcher { } private static func date(from value: Any?) -> Date? { + if let timestamp = self.double(from: value), timestamp > 0 { + let seconds = timestamp > 10_000_000_000 ? timestamp / 1000 : timestamp + return Date(timeIntervalSince1970: seconds) + } guard let s = value as? String else { return nil } let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } let fractional = ISO8601DateFormatter() fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let date = fractional.date(from: trimmed) { return date } + if let date = fractional.date(from: trimmed) { + return date + } let plain = ISO8601DateFormatter() plain.formatOptions = [.withInternetDateTime] return plain.date(from: trimmed) diff --git a/Sources/CodexBarCore/Providers/CommandCode/CommandCodeUsageSnapshot.swift b/Sources/CodexBarCore/Providers/CommandCode/CommandCodeUsageSnapshot.swift index 75be453d40..0d229022a9 100644 --- a/Sources/CodexBarCore/Providers/CommandCode/CommandCodeUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/CommandCode/CommandCodeUsageSnapshot.swift @@ -1,6 +1,6 @@ import Foundation -/// Parsed view of CommandCode `/internal/billing/credits` + `/internal/billing/subscriptions`. +/// Parsed view of Command Code billing credits, rolling limits, and subscription state. public struct CommandCodeUsageSnapshot: Sendable { /// USD remaining in the current monthly grant (`credits.monthlyCredits`). public let monthlyCreditsRemaining: Double @@ -10,6 +10,10 @@ public struct CommandCodeUsageSnapshot: Sendable { public let premiumMonthlyCredits: Double /// USD remaining in the open-source monthly grant (`credits.opensourceMonthlyCredits`). public let opensourceMonthlyCredits: Double + /// Rolling five-hour usage limit reported by the credits response. + public let fiveHourWindow: RateWindow? + /// Rolling weekly usage limit reported by the credits response. + public let weeklyWindow: RateWindow? /// Subscription plan, or nil when the user is on the free tier. public let plan: CommandCodePlanCatalog.Plan? /// `currentPeriodEnd` from the active subscription. @@ -25,6 +29,8 @@ public struct CommandCodeUsageSnapshot: Sendable { purchasedCredits: Double, premiumMonthlyCredits: Double, opensourceMonthlyCredits: Double, + fiveHourWindow: RateWindow? = nil, + weeklyWindow: RateWindow? = nil, plan: CommandCodePlanCatalog.Plan?, billingPeriodEnd: Date?, subscriptionStatus: String?, @@ -35,6 +41,8 @@ public struct CommandCodeUsageSnapshot: Sendable { self.purchasedCredits = purchasedCredits self.premiumMonthlyCredits = premiumMonthlyCredits self.opensourceMonthlyCredits = opensourceMonthlyCredits + self.fiveHourWindow = fiveHourWindow + self.weeklyWindow = weeklyWindow self.plan = plan self.billingPeriodEnd = billingPeriodEnd self.subscriptionStatus = subscriptionStatus @@ -54,7 +62,7 @@ public struct CommandCodeUsageSnapshot: Sendable { } public func toUsageSnapshot() -> UsageSnapshot { - let primary = self.makePrimaryWindow() + let monthly = self.makeMonthlyWindow() let identity = ProviderIdentitySnapshot( providerID: .commandcode, @@ -63,9 +71,9 @@ public struct CommandCodeUsageSnapshot: Sendable { loginMethod: self.makeLoginMethod()) return UsageSnapshot( - primary: primary, - secondary: nil, - tertiary: nil, + primary: self.fiveHourWindow, + secondary: self.weeklyWindow, + tertiary: monthly, providerCost: nil, commandCodeSubscriptionEnrichmentUnavailable: self.subscriptionEnrichmentUnavailable, commandCodeHasSubscriptionPlan: self.plan != nil, @@ -74,13 +82,13 @@ public struct CommandCodeUsageSnapshot: Sendable { identity: identity) } - private func makePrimaryWindow() -> RateWindow? { + private func makeMonthlyWindow() -> RateWindow? { guard let total = self.monthlyCreditsTotal, total > 0 else { // Free / unknown plan with no allowance — surface 100% so the bar renders empty. if self.monthlyCreditsRemaining > 0 || self.purchasedCredits > 0 { return RateWindow( usedPercent: 0, - windowMinutes: nil, + windowMinutes: ProviderPaceCapability.monthlyWindowSentinelMinutes, resetsAt: self.billingPeriodEnd, resetDescription: nil) } @@ -90,7 +98,7 @@ public struct CommandCodeUsageSnapshot: Sendable { let percent = UsagePercent(used: used, limit: total).displayClamped return RateWindow( usedPercent: percent, - windowMinutes: nil, + windowMinutes: ProviderPaceCapability.monthlyWindowSentinelMinutes, resetsAt: self.billingPeriodEnd, resetDescription: nil) } diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 13177794af..2f4f14efa3 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -319,6 +319,10 @@ public struct UsageSnapshot: Codable, Sendable { secondary: .value(secondary)) } + public func with(tertiary: RateWindow?) -> UsageSnapshot { + self.replacing(tertiary: .value(tertiary)) + } + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.primary = try container.decodeIfPresent(RateWindow.self, forKey: .primary) diff --git a/Tests/CodexBarTests/CommandCodeProviderTests.swift b/Tests/CodexBarTests/CommandCodeProviderTests.swift index 898f1606cc..6beb5e5c5e 100644 --- a/Tests/CodexBarTests/CommandCodeProviderTests.swift +++ b/Tests/CodexBarTests/CommandCodeProviderTests.swift @@ -30,6 +30,11 @@ struct CommandCodeProviderTests { #expect(descriptor.metadata.cliName == "commandcode") #expect(descriptor.branding.iconResourceName == "ProviderIcon-commandcode") #expect(descriptor.branding.iconStyle == .commandcode) + #expect(descriptor.metadata.sessionLabel == "5-hour") + #expect(descriptor.metadata.weeklyLabel == "Weekly") + #expect(descriptor.metadata.opusLabel == "Monthly") + #expect(descriptor.metadata.supportsOpus) + #expect(descriptor.fetchPlan.sourceModes == [.auto, .web]) } @Test diff --git a/Tests/CodexBarTests/CommandCodeQuotaTransitionTests.swift b/Tests/CodexBarTests/CommandCodeQuotaTransitionTests.swift index 618aa5281f..6d9b93ff05 100644 --- a/Tests/CodexBarTests/CommandCodeQuotaTransitionTests.swift +++ b/Tests/CodexBarTests/CommandCodeQuotaTransitionTests.swift @@ -6,7 +6,7 @@ import Testing @MainActor struct CommandCodeQuotaTransitionTests { @Test - func `display keeps prior primary only during subscription enrichment failure`() throws { + func `display keeps prior monthly window only during subscription enrichment failure`() throws { let plan = try #require(CommandCodePlanCatalog.plans.first { $0.monthlyCreditsUSD > 0 }) let availableWithPlan = self.snapshot(remaining: 6, plan: plan) let missingSubscription = self.snapshot( @@ -17,33 +17,57 @@ struct CommandCodeQuotaTransitionTests { let freeTier = self.snapshot(remaining: 0, plan: nil) let freeTierWithPurchasedCredits = self.snapshot(remaining: 0, purchasedCredits: 5, plan: nil) - #expect(missingSubscription.primary?.usedPercent == 0) - #expect(freeTierWithPurchasedCredits.primary?.usedPercent == 0) + #expect(missingSubscription.tertiary?.usedPercent == 0) + #expect(freeTierWithPurchasedCredits.tertiary?.usedPercent == 0) let stabilized = UsageStore.commandCodeSnapshotResolvingDepletionOnEnrichmentFailure( current: missingSubscription, previous: availableWithPlan) - #expect(stabilized.primary?.usedPercent == 100) + #expect(stabilized.tertiary?.usedPercent == 100) let stabilizedAgain = UsageStore.commandCodeSnapshotResolvingDepletionOnEnrichmentFailure( current: missingSubscription, previous: stabilized) - #expect(stabilizedAgain.primary?.usedPercent == 100) + #expect(stabilizedAgain.tertiary?.usedPercent == 100) let startupFailure = UsageStore.commandCodeSnapshotResolvingDepletionOnEnrichmentFailure( current: missingSubscription, previous: nil) - #expect(startupFailure.primary?.usedPercent == 0) + #expect(startupFailure.tertiary?.usedPercent == 0) let freeTierFailure = UsageStore.commandCodeSnapshotResolvingDepletionOnEnrichmentFailure( current: missingSubscription, previous: freeTierWithPurchasedCredits) - #expect(freeTierFailure.primary?.usedPercent == 0) + #expect(freeTierFailure.tertiary?.usedPercent == 0) let validFreeTier = UsageStore.commandCodeSnapshotResolvingDepletionOnEnrichmentFailure( current: freeTier, previous: availableWithPlan) - #expect(validFreeTier.primary == nil) + #expect(validFreeTier.tertiary == nil) + } + + @Test + func `subscription enrichment failure preserves rolling windows`() throws { + let plan = try #require(CommandCodePlanCatalog.plans.first { $0.monthlyCreditsUSD > 0 }) + let rolling = RateWindow( + usedPercent: 25, + windowMinutes: 5 * 60, + resetsAt: Date(timeIntervalSince1970: 1_780_000_000), + resetDescription: nil) + let availableWithPlan = self.snapshot(remaining: 6, plan: plan) + let missingSubscription = self.snapshot( + remaining: 0, + purchasedCredits: 5, + plan: nil, + subscriptionUnavailable: true, + fiveHourWindow: rolling) + + let stabilized = UsageStore.commandCodeSnapshotResolvingDepletionOnEnrichmentFailure( + current: missingSubscription, + previous: availableWithPlan) + + #expect(stabilized.primary == rolling) + #expect(stabilized.tertiary?.usedPercent == 100) } @Test @@ -53,15 +77,22 @@ struct CommandCodeQuotaTransitionTests { let notifier = NotifierSpy() let store = self.makeStore(settings: settings, notifier: notifier) let plan = try #require(CommandCodePlanCatalog.plans.first { $0.monthlyCreditsUSD > 0 }) - let depletedWithPlan = self.snapshot(remaining: 0, plan: plan) - let freeTier = self.snapshot(remaining: 0, plan: nil) + let availableWithPlan = self.snapshot( + remaining: 6, + plan: plan, + fiveHourWindow: self.rollingWindow(usedPercent: 0)) + let depletedWithPlan = self.snapshot( + remaining: 0, + plan: plan, + fiveHourWindow: self.rollingWindow(usedPercent: 100)) let missingSubscription = self.snapshot( remaining: 0, purchasedCredits: 5, plan: nil, - subscriptionUnavailable: true) + subscriptionUnavailable: true, + fiveHourWindow: self.rollingWindow(usedPercent: 100)) - store.handleSessionQuotaTransition(provider: .commandcode, snapshot: freeTier) + store.handleSessionQuotaTransition(provider: .commandcode, snapshot: availableWithPlan) #expect(notifier.posts.isEmpty) store.handleSessionQuotaTransition(provider: .commandcode, snapshot: depletedWithPlan) @@ -77,7 +108,7 @@ struct CommandCodeQuotaTransitionTests { #expect(notifier.posts.count(where: { $0.transition == .depleted }) == 1) - store.handleSessionQuotaTransition(provider: .commandcode, snapshot: freeTier) + store.handleSessionQuotaTransition(provider: .commandcode, snapshot: availableWithPlan) store.handleSessionQuotaTransition(provider: .commandcode, snapshot: depletedWithPlan) #expect(notifier.posts.count(where: { $0.transition == .depleted }) == 2) } @@ -91,28 +122,36 @@ struct CommandCodeQuotaTransitionTests { let store = self.makeStore(settings: settings, notifier: notifier) let plan = try #require(CommandCodePlanCatalog.plans.first { $0.monthlyCreditsUSD > 0 }) - store.handleQuotaWarningTransitions(provider: .commandcode, snapshot: self.snapshot(remaining: 6, plan: plan)) - store.handleQuotaWarningTransitions(provider: .commandcode, snapshot: self.snapshot(remaining: 4, plan: plan)) - let availableWithPlan = self.snapshot(remaining: 4, plan: plan) + let availableWithPlan = self.snapshot( + remaining: 6, + plan: plan, + fiveHourWindow: self.rollingWindow(usedPercent: 40)) + let warningWithPlan = self.snapshot( + remaining: 4, + plan: plan, + fiveHourWindow: self.rollingWindow(usedPercent: 60)) + store.handleQuotaWarningTransitions(provider: .commandcode, snapshot: availableWithPlan) + store.handleQuotaWarningTransitions(provider: .commandcode, snapshot: warningWithPlan) let missingSubscription = self.snapshot( remaining: 0, purchasedCredits: 5, plan: nil, - subscriptionUnavailable: true) + subscriptionUnavailable: true, + fiveHourWindow: self.rollingWindow(usedPercent: 60)) let stabilizedFailure = UsageStore.commandCodeSnapshotResolvingDepletionOnEnrichmentFailure( current: missingSubscription, - previous: availableWithPlan) + previous: warningWithPlan) store.handleQuotaWarningTransitions(provider: .commandcode, snapshot: stabilizedFailure) let repeatedFailure = UsageStore.commandCodeSnapshotResolvingDepletionOnEnrichmentFailure( current: missingSubscription, previous: stabilizedFailure) store.handleQuotaWarningTransitions(provider: .commandcode, snapshot: repeatedFailure) - store.handleQuotaWarningTransitions(provider: .commandcode, snapshot: self.snapshot(remaining: 4, plan: plan)) + store.handleQuotaWarningTransitions(provider: .commandcode, snapshot: warningWithPlan) #expect(notifier.quotaWarningPosts.count == 1) - store.handleQuotaWarningTransitions(provider: .commandcode, snapshot: self.snapshot(remaining: 0, plan: nil)) - store.handleQuotaWarningTransitions(provider: .commandcode, snapshot: self.snapshot(remaining: 4, plan: plan)) + store.handleQuotaWarningTransitions(provider: .commandcode, snapshot: availableWithPlan) + store.handleQuotaWarningTransitions(provider: .commandcode, snapshot: warningWithPlan) #expect(notifier.quotaWarningPosts.count == 2) } @@ -141,13 +180,15 @@ struct CommandCodeQuotaTransitionTests { remaining: Double, purchasedCredits: Double = 0, plan: CommandCodePlanCatalog.Plan?, - subscriptionUnavailable: Bool = false) -> UsageSnapshot + subscriptionUnavailable: Bool = false, + fiveHourWindow: RateWindow? = nil) -> UsageSnapshot { CommandCodeUsageSnapshot( monthlyCreditsRemaining: remaining, purchasedCredits: purchasedCredits, premiumMonthlyCredits: 0, opensourceMonthlyCredits: 0, + fiveHourWindow: fiveHourWindow, plan: plan, billingPeriodEnd: nil, subscriptionStatus: plan == nil ? nil : "active", @@ -155,6 +196,14 @@ struct CommandCodeQuotaTransitionTests { .toUsageSnapshot() } + private func rollingWindow(usedPercent: Double) -> RateWindow { + RateWindow( + usedPercent: usedPercent, + windowMinutes: 5 * 60, + resetsAt: Date(timeIntervalSince1970: 1_780_000_000), + resetDescription: nil) + } + private final class NotifierSpy: SessionQuotaNotifying { private(set) var posts: [(transition: SessionQuotaTransition, provider: UsageProvider)] = [] private(set) var quotaWarningPosts: [QuotaWarningEvent] = [] diff --git a/Tests/CodexBarTests/CommandCodeUsageFetcherTests.swift b/Tests/CodexBarTests/CommandCodeUsageFetcherTests.swift index 5e76290e27..b9c0446bba 100644 --- a/Tests/CodexBarTests/CommandCodeUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CommandCodeUsageFetcherTests.swift @@ -8,6 +8,13 @@ import FoundationNetworking /// Tests for `CommandCodeUsageFetcher` parsers and the cookie/snapshot derivation, /// using real responses captured from api.commandcode.ai for an active "individual-go" plan. struct CommandCodeUsageFetcherTests { + private static func creditsFixture(_ name: String) throws -> Data { + try Data(contentsOf: #require(Bundle.module.url( + forResource: name, + withExtension: "json", + subdirectory: "Fixtures/Providers/CommandCode"))) + } + private static let creditsJSON = """ {"credits":{"belowThreshold":false,"creditThreshold":0,"monthlyCredits":8.7784,\ "purchasedCredits":0,"premiumMonthlyCredits":0,"opensourceMonthlyCredits":8.7784}} @@ -33,6 +40,36 @@ struct CommandCodeUsageFetcherTests { #expect(payload.opensourceMonthlyCredits == 8.7784) } + @Test + func `parses rolling windows at response root`() throws { + let payload = try CommandCodeUsageFetcher.parseCredits( + data: Self.creditsFixture("window-limits-root")) + + #expect(payload.monthlyCredits == 8.5) + let fiveHour = try #require(payload.fiveHourWindow) + #expect(fiveHour.usedPercent == 25) + #expect(fiveHour.windowMinutes == 5 * 60) + #expect(fiveHour.resetsAt == Date(timeIntervalSince1970: 1_780_000_000)) + let weekly = try #require(payload.weeklyWindow) + #expect(weekly.usedPercent == 10) + #expect(weekly.windowMinutes == 7 * 24 * 60) + #expect(weekly.resetsAt == Date(timeIntervalSince1970: 1_780_100_000)) + } + + @Test + func `parses rolling windows nested in credits`() throws { + let payload = try CommandCodeUsageFetcher.parseCredits( + data: Self.creditsFixture("window-limits-nested")) + + #expect(payload.monthlyCredits == 7.25) + let fiveHour = try #require(payload.fiveHourWindow) + #expect(fiveHour.usedPercent == 25) + #expect(fiveHour.resetsAt == Date(timeIntervalSince1970: 1_780_200_000)) + let weekly = try #require(payload.weeklyWindow) + #expect(weekly.usedPercent == 20) + #expect(weekly.resetsAt == Date(timeIntervalSince1970: 1_780_300_000)) + } + @Test func `parses subscription payload`() throws { let data = try #require(Self.subscriptionJSON.data(using: .utf8)) @@ -311,6 +348,16 @@ struct CommandCodeUsageFetcherTests { purchasedCredits: 0, premiumMonthlyCredits: 0, opensourceMonthlyCredits: 8.7784, + fiveHourWindow: RateWindow( + usedPercent: 25, + windowMinutes: 5 * 60, + resetsAt: Date(timeIntervalSince1970: 1_779_000_000), + resetDescription: nil), + weeklyWindow: RateWindow( + usedPercent: 10, + windowMinutes: 7 * 24 * 60, + resetsAt: Date(timeIntervalSince1970: 1_779_500_000), + resetDescription: nil), plan: plan, billingPeriodEnd: Date(timeIntervalSince1970: 1_780_000_000), subscriptionStatus: "active", @@ -319,9 +366,12 @@ struct CommandCodeUsageFetcherTests { #expect(abs((snapshot.monthlyCreditsUsed ?? -1) - 1.2216) < 0.0001) let usage = snapshot.toUsageSnapshot() - let primary = try #require(usage.primary) - #expect(abs(primary.usedPercent - 12.216) < 0.001) - #expect(primary.resetsAt == Date(timeIntervalSince1970: 1_780_000_000)) + #expect(usage.primary?.usedPercent == 25) + #expect(usage.secondary?.usedPercent == 10) + let monthly = try #require(usage.tertiary) + #expect(abs(monthly.usedPercent - 12.216) < 0.001) + #expect(monthly.windowMinutes == ProviderPaceCapability.monthlyWindowSentinelMinutes) + #expect(monthly.resetsAt == Date(timeIntervalSince1970: 1_780_000_000)) #expect(usage.identity?.loginMethod == "Go · $1.22 of $10.00") } @@ -336,7 +386,7 @@ struct CommandCodeUsageFetcherTests { billingPeriodEnd: nil, subscriptionStatus: nil) - #expect(snapshot.toUsageSnapshot().primary == nil) + #expect(snapshot.toUsageSnapshot().tertiary == nil) } @Test diff --git a/Tests/CodexBarTests/Fixtures/Providers/CommandCode/window-limits-nested.json b/Tests/CodexBarTests/Fixtures/Providers/CommandCode/window-limits-nested.json new file mode 100644 index 0000000000..da2899050f --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/Providers/CommandCode/window-limits-nested.json @@ -0,0 +1,20 @@ +{ + "credits": { + "monthlyCredits": 7.25, + "purchasedCredits": 2, + "premiumMonthlyCredits": 0, + "opensourceMonthlyCredits": 0, + "windowLimits": { + "fiveHour": { + "cap": "4", + "used": "1", + "resetAt": "1780200000" + }, + "weekly": { + "cap": 20, + "used": 4, + "resetAt": 1780300000000 + } + } + } +} diff --git a/Tests/CodexBarTests/Fixtures/Providers/CommandCode/window-limits-root.json b/Tests/CodexBarTests/Fixtures/Providers/CommandCode/window-limits-root.json new file mode 100644 index 0000000000..cde787e25b --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/Providers/CommandCode/window-limits-root.json @@ -0,0 +1,20 @@ +{ + "credits": { + "monthlyCredits": 8.5, + "purchasedCredits": 0, + "premiumMonthlyCredits": 0, + "opensourceMonthlyCredits": 0 + }, + "windowLimits": { + "fiveHour": { + "cap": 3, + "used": 0.75, + "resetAt": 1780000000000 + }, + "weekly": { + "cap": 15, + "used": 1.5, + "resetAt": 1780100000000 + } + } +} diff --git a/Tests/CodexBarTests/ProviderPaceCapabilityTests.swift b/Tests/CodexBarTests/ProviderPaceCapabilityTests.swift index 5bebc335a8..300a93f88b 100644 --- a/Tests/CodexBarTests/ProviderPaceCapabilityTests.swift +++ b/Tests/CodexBarTests/ProviderPaceCapabilityTests.swift @@ -117,7 +117,7 @@ struct ProviderPaceCapabilityTests { && timeUntilReset <= TimeInterval(windowMinutes) * 60 case .kimi: return window.windowMinutes == self.weeklyWindowMinutes - case .alibaba, .alibabatokenplan, .amp, .doubao, .mimo, .notion, .opencodego, .stepfun: + case .alibaba, .alibabatokenplan, .amp, .commandcode, .doubao, .mimo, .notion, .opencodego, .stepfun: return window.windowMinutes == self.monthlyWindowSentinelMinutes default: return false @@ -131,7 +131,7 @@ struct ProviderPaceCapabilityTests { switch provider { case .copilot: window.windowMinutes == nil - case .alibaba, .alibabatokenplan, .amp, .doubao, .mimo, .notion, .opencodego, .stepfun: + case .alibaba, .alibabatokenplan, .amp, .commandcode, .doubao, .mimo, .notion, .opencodego, .stepfun: window.windowMinutes == self.monthlyWindowSentinelMinutes default: false diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationCelebrationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationCelebrationTests.swift index 9aa80e39c3..479be43c50 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationCelebrationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationCelebrationTests.swift @@ -1420,7 +1420,7 @@ extension UsageStorePlanUtilizationTests { @MainActor @Test - func `session quota celebration ignores command code subscription enrichment failure`() async { + func `session quota celebration keeps command code rolling window during subscription enrichment failure`() async { let store = Self.makeStore() let recorder = SessionLimitResetEventRecorder(provider: .commandcode, accountLabel: nil) defer { recorder.invalidate() } @@ -1439,11 +1439,11 @@ extension UsageStorePlanUtilizationTests { let firstDate = Date(timeIntervalSince1970: 1_700_000_000) let before = snapshot(usedPercent: 80, enrichmentUnavailable: false, updatedAt: firstDate) - let failedEnrichment = snapshot( + let rollingReset = snapshot( usedPercent: 0, enrichmentUnavailable: true, updatedAt: firstDate.addingTimeInterval(3600)) - let validReset = snapshot( + let confirmedReset = snapshot( usedPercent: 0, enrichmentUnavailable: false, updatedAt: firstDate.addingTimeInterval(7200)) @@ -1451,14 +1451,14 @@ extension UsageStorePlanUtilizationTests { await store.recordPlanUtilizationHistorySample(provider: .commandcode, snapshot: before, now: before.updatedAt) await store.recordPlanUtilizationHistorySample( provider: .commandcode, - snapshot: failedEnrichment, - now: failedEnrichment.updatedAt) - #expect(recorder.events.isEmpty) + snapshot: rollingReset, + now: rollingReset.updatedAt) + #expect(recorder.events.count == 1) await store.recordPlanUtilizationHistorySample( provider: .commandcode, - snapshot: validReset, - now: validReset.updatedAt) + snapshot: confirmedReset, + now: confirmedReset.updatedAt) #expect(recorder.events.count == 1) } diff --git a/docs/command-code.md b/docs/command-code.md index c7c01e34e2..ca245f137e 100644 --- a/docs/command-code.md +++ b/docs/command-code.md @@ -1,5 +1,5 @@ --- -summary: "Command Code provider notes: cookie authentication and monthly credit parsing." +summary: "Command Code provider notes: cookie authentication and usage-window parsing." read_when: - Debugging Command Code cookie import or usage parsing - Updating Command Code billing or credit display @@ -15,8 +15,9 @@ to your other AI coding providers. - `https://api.commandcode.ai` billing endpoints, authenticated with the signed-in Command Code web session. -- The provider reads monthly credit usage, plan allowance, remaining credits, - and billing-cycle reset timing when the account data is available. +- The provider reads 5-hour and weekly rolling limits alongside monthly credit + usage, plan allowance, remaining credits, and billing-cycle reset timing when + the account data is available. ## Authentication @@ -40,7 +41,8 @@ provide the Command Code `Cookie` header in `cookieHeader`; both `auto` and ## Display - The menu bar item and provider card use the Command Code icon and label. -- The primary row shows monthly credits used/remaining. +- The primary and secondary rows show 5-hour and weekly rolling usage. +- The tertiary row shows monthly credits used/remaining. - Widgets do not expose Command Code in the provider picker yet. ## Related files diff --git a/docs/providers.md b/docs/providers.md index 3b3adceb6c..f969efbfe6 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -476,7 +476,7 @@ provider-specific cookie validation, endpoints, login detection, and error trans ## Command Code - Browser session cookies from automatic import or manual `Cookie:` header. - Linux CLI supports configured manual cookies; automatic browser import remains macOS-only. -- Reads monthly USD credits and billing-cycle usage from `api.commandcode.ai`. +- Reads 5-hour and weekly rolling limits plus monthly USD credits and billing-cycle usage from `api.commandcode.ai`. - Automatic import looks for better-auth session cookies from `commandcode.ai` / `www.commandcode.ai`. - Status: none yet. - Details: `docs/command-code.md`.