Skip to content

Commit 2af39f5

Browse files
committed
refactor: data-drive provider pace capability
1 parent f8636cb commit 2af39f5

10 files changed

Lines changed: 192 additions & 38 deletions

Sources/CodexBar/MenuCardView+ModelHelpers.swift

Lines changed: 4 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -518,7 +518,8 @@ extension UsageMenuCardView.Model {
518518
input: Input,
519519
pace: UsagePace? = nil) -> PaceDetail?
520520
{
521-
guard self.supportsResetWindowPace(provider: input.provider, window: window, now: input.now),
521+
let capability = ProviderDescriptorRegistry.descriptor(for: input.provider).pace
522+
guard capability.supportsResetWindowPace(window: window, now: input.now),
522523
window.remainingPercent > 0
523524
else { return nil }
524525
let paceWindow = Self.resetWindowForPace(provider: input.provider, window: window)
@@ -536,34 +537,10 @@ extension UsageMenuCardView.Model {
536537
showUsed: input.usageBarsShowUsed)
537538
}
538539

539-
private static let weeklyWindowMinutes = 7 * 24 * 60
540-
private static let monthlyWindowSentinelMinutes = 30 * 24 * 60
541-
542-
private static func supportsResetWindowPace(provider: UsageProvider, window: RateWindow, now: Date) -> Bool {
543-
switch provider {
544-
case .copilot:
545-
return window.resetsAt != nil
546-
case .cursor:
547-
return window.windowMinutes != nil
548-
case .grok:
549-
guard GrokProviderDescriptor.primaryLabel(window: window, now: now) == "Weekly",
550-
let resetsAt = window.resetsAt
551-
else { return false }
552-
let windowMinutes = window.windowMinutes ?? self.weeklyWindowMinutes
553-
let timeUntilReset = resetsAt.timeIntervalSince(now)
554-
return windowMinutes > 0
555-
&& timeUntilReset > 0
556-
&& timeUntilReset <= TimeInterval(windowMinutes) * 60
557-
case .alibaba, .alibabatokenplan, .doubao, .opencodego:
558-
return window.windowMinutes == self.monthlyWindowSentinelMinutes
559-
default:
560-
return false
561-
}
562-
}
563-
564540
private static func resetWindowForPace(provider: UsageProvider, window: RateWindow) -> RateWindow {
565541
// Provider snapshots use 30 days as a monthly sentinel; use the reset date for the real calendar-cycle length.
566-
guard self.usesInferredMonthlyDuration(provider: provider, window: window),
542+
let pace = ProviderDescriptorRegistry.descriptor(for: provider).pace
543+
guard pace.usesInferredMonthlyDuration(window: window),
567544
let resetsAt = window.resetsAt,
568545
let minutes = self.inferredMonthlyWindowMinutes(endingAt: resetsAt)
569546
else { return window }
@@ -576,17 +553,6 @@ extension UsageMenuCardView.Model {
576553
isSyntheticPlaceholder: window.isSyntheticPlaceholder)
577554
}
578555

579-
private static func usesInferredMonthlyDuration(provider: UsageProvider, window: RateWindow) -> Bool {
580-
switch provider {
581-
case .copilot:
582-
window.windowMinutes == nil
583-
case .alibaba, .alibabatokenplan, .doubao, .opencodego:
584-
window.windowMinutes == self.monthlyWindowSentinelMinutes
585-
default:
586-
false
587-
}
588-
}
589-
590556
private static func inferredMonthlyWindowMinutes(endingAt resetsAt: Date) -> Int? {
591557
var calendar = Calendar(identifier: .gregorian)
592558
calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? calendar.timeZone

Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanProviderDescriptor.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ public enum AlibabaCodingPlanProviderDescriptor {
5454
tokenCost: ProviderTokenCostConfig(
5555
supportsTokenCost: false,
5656
noDataMessage: { "Alibaba Coding Plan cost summary is not supported." }),
57+
pace: .calendarMonthResetWindow,
5758
fetchPlan: ProviderFetchPlan(
5859
sourceModes: [.auto, .web, .api],
5960
pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)),

Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ public enum AlibabaTokenPlanProviderDescriptor {
5454
tokenCost: ProviderTokenCostConfig(
5555
supportsTokenCost: false,
5656
noDataMessage: { "Alibaba Token Plan cost summary is not supported." }),
57+
pace: .calendarMonthResetWindow,
5758
fetchPlan: ProviderFetchPlan(
5859
sourceModes: [.auto, .web],
5960
pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)),

Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ public enum CopilotProviderDescriptor {
3535
tokenCost: ProviderTokenCostConfig(
3636
supportsTokenCost: false,
3737
noDataMessage: { "Copilot cost summary is not supported." }),
38+
pace: ProviderPaceCapability(
39+
resetWindowPace: .resetDatePresent,
40+
inferredMonthlyDuration: .windowDurationMissing),
3841
fetchPlan: ProviderFetchPlan(
3942
sourceModes: [.auto, .api],
4043
pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [CopilotAPIFetchStrategy()] })),

Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ public enum CursorProviderDescriptor {
3636
tokenCost: ProviderTokenCostConfig(
3737
supportsTokenCost: true,
3838
noDataMessage: { "No Cursor cost usage found. Sign in to Cursor in your browser or the Cursor app." }),
39+
pace: ProviderPaceCapability(resetWindowPace: .windowDurationPresent),
3940
fetchPlan: ProviderFetchPlan(
4041
sourceModes: [.auto, .cli, .web],
4142
pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [CursorStatusFetchStrategy()] })),

Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ public enum DoubaoProviderDescriptor {
4444
tokenCost: ProviderTokenCostConfig(
4545
supportsTokenCost: false,
4646
noDataMessage: { "Doubao cost summary is not available." }),
47+
pace: .calendarMonthResetWindow,
4748
fetchPlan: ProviderFetchPlan(
4849
sourceModes: [.auto, .cli, .api],
4950
pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)),

Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ public enum GrokProviderDescriptor {
3737
tokenCost: ProviderTokenCostConfig(
3838
supportsTokenCost: false,
3939
noDataMessage: { "Grok cost summary is not supported yet." }),
40+
pace: ProviderPaceCapability(resetWindowPace: .custom { window, now in
41+
guard Self.primaryLabel(window: window, now: now) == "Weekly",
42+
let resetsAt = window.resetsAt
43+
else { return false }
44+
let windowMinutes = window.windowMinutes ?? 7 * 24 * 60
45+
let timeUntilReset = resetsAt.timeIntervalSince(now)
46+
return windowMinutes > 0
47+
&& timeUntilReset > 0
48+
&& timeUntilReset <= TimeInterval(windowMinutes) * 60
49+
}),
4050
fetchPlan: ProviderFetchPlan(
4151
sourceModes: [.auto, .cli, .web],
4252
pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)),

Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ public enum OpenCodeGoProviderDescriptor {
3737
noDataMessage: {
3838
"No OpenCode Go local usage history found in ~/.local/share/opencode/opencode.db."
3939
}),
40+
pace: .calendarMonthResetWindow,
4041
fetchPlan: ProviderFetchPlan(
4142
sourceModes: [.auto, .web],
4243
pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)),

Sources/CodexBarCore/Providers/ProviderDescriptor.swift

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,79 @@ public struct ProviderTokenCostConfig: Sendable {
1010
}
1111
}
1212

13+
public enum ProviderPaceWindowRule: Sendable {
14+
case unsupported
15+
case resetDatePresent
16+
case windowDurationPresent
17+
case windowDuration(minutes: Int)
18+
case custom(@Sendable (_ window: RateWindow, _ now: Date) -> Bool)
19+
20+
public func matches(window: RateWindow, now: Date) -> Bool {
21+
switch self {
22+
case .unsupported:
23+
false
24+
case .resetDatePresent:
25+
window.resetsAt != nil
26+
case .windowDurationPresent:
27+
window.windowMinutes != nil
28+
case let .windowDuration(minutes):
29+
window.windowMinutes == minutes
30+
case let .custom(predicate):
31+
predicate(window, now)
32+
}
33+
}
34+
}
35+
36+
public enum ProviderPaceDurationRule: Sendable {
37+
case unsupported
38+
case windowDurationMissing
39+
case windowDuration(minutes: Int)
40+
41+
public func matches(window: RateWindow) -> Bool {
42+
switch self {
43+
case .unsupported:
44+
false
45+
case .windowDurationMissing:
46+
window.windowMinutes == nil
47+
case let .windowDuration(minutes):
48+
window.windowMinutes == minutes
49+
}
50+
}
51+
}
52+
53+
public struct ProviderPaceCapability: Sendable {
54+
public static let monthlyWindowSentinelMinutes = 30 * 24 * 60
55+
public static let unsupported = ProviderPaceCapability()
56+
public static let calendarMonthResetWindow = ProviderPaceCapability(
57+
resetWindowPace: .windowDuration(minutes: ProviderPaceCapability.monthlyWindowSentinelMinutes),
58+
inferredMonthlyDuration: .windowDuration(minutes: ProviderPaceCapability.monthlyWindowSentinelMinutes))
59+
60+
public let resetWindowPace: ProviderPaceWindowRule
61+
public let inferredMonthlyDuration: ProviderPaceDurationRule
62+
63+
public init(
64+
resetWindowPace: ProviderPaceWindowRule = .unsupported,
65+
inferredMonthlyDuration: ProviderPaceDurationRule = .unsupported)
66+
{
67+
self.resetWindowPace = resetWindowPace
68+
self.inferredMonthlyDuration = inferredMonthlyDuration
69+
}
70+
71+
public func supportsResetWindowPace(window: RateWindow, now: Date) -> Bool {
72+
self.resetWindowPace.matches(window: window, now: now)
73+
}
74+
75+
public func usesInferredMonthlyDuration(window: RateWindow) -> Bool {
76+
self.inferredMonthlyDuration.matches(window: window)
77+
}
78+
}
79+
1380
public struct ProviderDescriptor: Sendable {
1481
public let id: UsageProvider
1582
public let metadata: ProviderMetadata
1683
public let branding: ProviderBranding
1784
public let tokenCost: ProviderTokenCostConfig
85+
public let pace: ProviderPaceCapability
1886
public let fetchPlan: ProviderFetchPlan
1987
public let cli: ProviderCLIConfig
2088

@@ -23,13 +91,15 @@ public struct ProviderDescriptor: Sendable {
2391
metadata: ProviderMetadata,
2492
branding: ProviderBranding,
2593
tokenCost: ProviderTokenCostConfig,
94+
pace: ProviderPaceCapability = .unsupported,
2695
fetchPlan: ProviderFetchPlan,
2796
cli: ProviderCLIConfig)
2897
{
2998
self.id = id
3099
self.metadata = metadata
31100
self.branding = branding
32101
self.tokenCost = tokenCost
102+
self.pace = pace
33103
self.fetchPlan = fetchPlan
34104
self.cli = cli
35105
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import CodexBarCore
2+
import Foundation
3+
import Testing
4+
5+
struct ProviderPaceCapabilityTests {
6+
private static let weeklyWindowMinutes = 7 * 24 * 60
7+
private static let monthlyWindowSentinelMinutes = 30 * 24 * 60
8+
9+
@Test
10+
func `descriptor pace capabilities preserve the legacy provider mapping`() {
11+
let now = Date(timeIntervalSince1970: 1_750_000_000)
12+
let fixtures: [RateWindow] = [
13+
Self.window(minutes: nil, resetsAt: nil),
14+
Self.window(minutes: nil, resetsAt: now.addingTimeInterval(4 * 24 * 60 * 60)),
15+
Self.window(minutes: nil, resetsAt: now.addingTimeInterval(8 * 24 * 60 * 60)),
16+
Self.window(minutes: nil, resetsAt: now.addingTimeInterval(30 * 24 * 60 * 60)),
17+
Self.window(minutes: 60, resetsAt: now.addingTimeInterval(30 * 60)),
18+
Self.window(minutes: Self.weeklyWindowMinutes, resetsAt: nil),
19+
Self.window(minutes: Self.weeklyWindowMinutes, resetsAt: now.addingTimeInterval(4 * 24 * 60 * 60)),
20+
Self.window(minutes: Self.weeklyWindowMinutes, resetsAt: now.addingTimeInterval(8 * 24 * 60 * 60)),
21+
Self.window(minutes: Self.monthlyWindowSentinelMinutes, resetsAt: nil),
22+
Self.window(
23+
minutes: Self.monthlyWindowSentinelMinutes,
24+
resetsAt: now.addingTimeInterval(20 * 24 * 60 * 60)),
25+
Self.window(minutes: 0, resetsAt: now.addingTimeInterval(60)),
26+
Self.window(minutes: Self.weeklyWindowMinutes, resetsAt: now.addingTimeInterval(-60)),
27+
]
28+
29+
for provider in UsageProvider.allCases {
30+
let capability = ProviderDescriptorRegistry.descriptor(for: provider).pace
31+
for window in fixtures {
32+
let actualResetWindowPace = capability.supportsResetWindowPace(window: window, now: now)
33+
let legacyResetWindowPace = Self.legacySupportsResetWindowPace(
34+
provider: provider,
35+
window: window,
36+
now: now)
37+
#expect(
38+
actualResetWindowPace == legacyResetWindowPace,
39+
"Reset-window pace changed for \(provider.rawValue), window=\(String(describing: window)).")
40+
41+
let actualMonthlyInference = capability.usesInferredMonthlyDuration(window: window)
42+
let legacyMonthlyInference = Self.legacyUsesInferredMonthlyDuration(
43+
provider: provider,
44+
window: window)
45+
#expect(
46+
actualMonthlyInference == legacyMonthlyInference,
47+
"Monthly inference changed for \(provider.rawValue), window=\(String(describing: window)).")
48+
}
49+
}
50+
}
51+
52+
private static func window(minutes: Int?, resetsAt: Date?) -> RateWindow {
53+
RateWindow(
54+
usedPercent: 50,
55+
windowMinutes: minutes,
56+
resetsAt: resetsAt,
57+
resetDescription: nil)
58+
}
59+
60+
/// Snapshot of the switches removed from UsageMenuCardView.Model.
61+
private static func legacySupportsResetWindowPace(
62+
provider: UsageProvider,
63+
window: RateWindow,
64+
now: Date) -> Bool
65+
{
66+
switch provider {
67+
case .copilot:
68+
return window.resetsAt != nil
69+
case .cursor:
70+
return window.windowMinutes != nil
71+
case .grok:
72+
guard GrokProviderDescriptor.primaryLabel(window: window, now: now) == "Weekly",
73+
let resetsAt = window.resetsAt
74+
else { return false }
75+
let windowMinutes = window.windowMinutes ?? self.weeklyWindowMinutes
76+
let timeUntilReset = resetsAt.timeIntervalSince(now)
77+
return windowMinutes > 0
78+
&& timeUntilReset > 0
79+
&& timeUntilReset <= TimeInterval(windowMinutes) * 60
80+
case .alibaba, .alibabatokenplan, .doubao, .opencodego:
81+
return window.windowMinutes == self.monthlyWindowSentinelMinutes
82+
default:
83+
return false
84+
}
85+
}
86+
87+
private static func legacyUsesInferredMonthlyDuration(
88+
provider: UsageProvider,
89+
window: RateWindow) -> Bool
90+
{
91+
switch provider {
92+
case .copilot:
93+
window.windowMinutes == nil
94+
case .alibaba, .alibabatokenplan, .doubao, .opencodego:
95+
window.windowMinutes == self.monthlyWindowSentinelMinutes
96+
default:
97+
false
98+
}
99+
}
100+
}

0 commit comments

Comments
 (0)