Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions Sources/App/QuotaViewModel+Lifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ extension QuotaViewModel {

if AutoRefreshPreferences.mode == .smart {
if case let .loaded(quota) = providerStates[providerId] {
_ = smartRefreshPolicy.recordSuccess(quota, for: providerId)
_ = recordSmartSuccess(quota, for: providerId)
}
}
if fetchTasks[providerId] == nil {
Expand All @@ -168,7 +168,7 @@ extension QuotaViewModel {
else {
continue
}
_ = smartRefreshPolicy.recordSuccess(quota, for: providerId)
_ = recordSmartSuccess(quota, for: providerId)
}
}

Expand Down Expand Up @@ -209,7 +209,7 @@ extension QuotaViewModel {
switch result {
case let .success(quota):
if !suppressSmartSuccess {
_ = smartRefreshPolicy.recordSuccess(quota, for: providerId)
_ = recordSmartSuccess(quota, for: providerId)
}
case .failure:
_ = smartRefreshPolicy.recordFailure(for: providerId)
Expand All @@ -219,6 +219,20 @@ extension QuotaViewModel {
startAutoRefresh(for: providerId)
}

@discardableResult
func recordSmartSuccess(
_ quota: ProviderQuota,
for providerId: String
) -> SmartRefreshPolicy.Decision {
let decision = smartRefreshPolicy.recordSuccess(quota, for: providerId)
let reasons = decision.reasons.map(\.rawValue).sorted().joined(separator: ",")
log(
"smartRefresh: provider=\(providerId) classification=\(decision.classification) "
+ "cadence=\(decision.cadence) reasons=\(reasons)"
)
return decision
}

func syncFastRefreshStatus(for providerId: String) {
let shouldShowStatus = AutoRefreshPreferences.mode == .smart
&& isEligibleForAutoRefresh(providerId)
Expand Down
4 changes: 2 additions & 2 deletions Sources/Core/AutoRefreshPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ public enum AutoRefreshMode: String, CaseIterable, Hashable, Sendable {
public enum AutoRefreshPreferences {
public static let defaultSlowInterval: TimeInterval = 5 * 60
public static let defaultFastInterval: TimeInterval = 30
public static let slowIntervalOptions: [TimeInterval] = [60, 5 * 60, 15 * 60, 30 * 60, 60 * 60]
public static let fastIntervalOptions: [TimeInterval] = [10, 15, 30, 45, 60]
public static let slowIntervalOptions: [TimeInterval] = (1 ... 60).map { TimeInterval($0 * 60) }
public static let fastIntervalOptions: [TimeInterval] = stride(from: 10, through: 60, by: 5).map(TimeInterval.init)

private nonisolated(unsafe) static var defaults: UserDefaults = .standard

Expand Down
44 changes: 44 additions & 0 deletions Sources/Core/ProviderProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ public struct ProviderQuota: Sendable {
public let lastUpdated: Date
public let error: String?
public let isStale: Bool
public let activityObservation: ProviderActivityObservation?
/// Optional peak-hours pricing config. Providers that have time-based
/// multipliers (e.g. z.ai's GLM Coding Plan) populate this so the view
/// can render a peak/off-peak block without any provider-specific code.
Expand All @@ -21,6 +22,7 @@ public struct ProviderQuota: Sendable {
lastUpdated: Date,
error: String? = nil,
isStale: Bool = false,
activityObservation: ProviderActivityObservation? = nil,
peakHoursConfig: PeakHoursConfig? = nil
) {
self.providerId = providerId
Expand All @@ -30,10 +32,52 @@ public struct ProviderQuota: Sendable {
self.lastUpdated = lastUpdated
self.error = error
self.isStale = isStale
self.activityObservation = activityObservation
self.peakHoursConfig = peakHoursConfig
}
}

public struct ProviderActivityObservation: Equatable, Sendable {
public let metrics: [ProviderActivityMetric]
public let availability: ProviderAvailability?

public init(
metrics: [ProviderActivityMetric] = [],
availability: ProviderAvailability? = nil
) {
self.metrics = metrics
self.availability = availability
}
}

public struct ProviderActivityMetric: Equatable, Sendable {
public enum Kind: String, Equatable, Sendable {
case usage
case credits
}

public enum Value: Equatable, Sendable {
case number(Decimal)
case discrete(String)
}

public let id: String
public let kind: Kind
public let value: Value

public init(id: String, kind: Kind, value: Value) {
self.id = id
self.kind = kind
self.value = value
}
}

public enum ProviderAvailability: Equatable, Sendable {
case available
case unavailable
case unknown
}

// MARK: - Peak-hours config

/// Provider-agnostic: adding a new provider with peak hours requires no
Expand Down
188 changes: 107 additions & 81 deletions Sources/Core/SmartRefreshPolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,34 @@ public struct SmartRefreshPolicy: Sendable {
case fast
}

public enum Classification: Equatable, Sendable {
case baseline
case unchanged
case changed
}

public enum ChangeReason: String, CaseIterable, Hashable, Sendable {
case usage
case credits
case availability
}

public struct Decision: Equatable, Sendable {
public let classification: Classification
public let cadence: Cadence
public let reasons: Set<ChangeReason>

init(
classification: Classification,
cadence: Cadence,
reasons: Set<ChangeReason> = []
) {
self.classification = classification
self.cadence = cadence
self.reasons = reasons
}
}

private var states: [String: State] = [:]

public init() {}
Expand All @@ -14,33 +42,38 @@ public struct SmartRefreshPolicy: Sendable {
public mutating func recordSuccess(
_ quota: ProviderQuota,
for providerId: String
) -> Cadence {
let snapshot = UsageSnapshot(quota: quota)
) -> Decision {
let snapshot = ActivitySnapshot(observation: quota.activityObservation)
var state = states[providerId] ?? State()

guard let previousSnapshot = state.snapshot else {
state.snapshot = snapshot
states[providerId] = state
return state.cadence
return Decision(classification: .baseline, cadence: state.cadence)
}

state.snapshot = snapshot
guard previousSnapshot != snapshot else {
if state.cadence == .fast {
state.consecutiveUnchangedChecks += 1
if state.consecutiveUnchangedChecks == Self.unchangedChecksBeforeSlowing {
state.cadence = .slow
state.consecutiveUnchangedChecks = 0
}
}
let reasons = previousSnapshot.changeReasons(comparedTo: snapshot)
guard reasons.isEmpty else {
state.cadence = .fast
state.consecutiveUnchangedChecks = 0
states[providerId] = state
return state.cadence
return Decision(
classification: .changed,
cadence: state.cadence,
reasons: reasons
)
}

state.cadence = .fast
state.consecutiveUnchangedChecks = 0
if state.cadence == .fast {
state.consecutiveUnchangedChecks += 1
if state.consecutiveUnchangedChecks == Self.unchangedChecksBeforeSlowing {
state.cadence = .slow
state.consecutiveUnchangedChecks = 0
}
}
states[providerId] = state
return state.cadence
return Decision(classification: .unchanged, cadence: state.cadence)
}

@discardableResult
Expand Down Expand Up @@ -73,87 +106,80 @@ public struct SmartRefreshPolicy: Sendable {

private extension SmartRefreshPolicy {
struct State: Sendable {
var snapshot: UsageSnapshot?
var snapshot: ActivitySnapshot?
var cadence: Cadence = .slow
var consecutiveUnchangedChecks = 0
}

struct UsageSnapshot: Equatable, Sendable {
let lines: [UsageLineSnapshot]
struct ActivitySnapshot: Equatable, Sendable {
let metrics: [ProviderActivityMetric]
let availability: ProviderAvailability?

init(quota: ProviderQuota) {
lines = quota.lines.map(UsageLineSnapshot.init).sorted()
}
}
init(observation: ProviderActivityObservation?) {
guard let observation else {
metrics = []
availability = nil
return
}

struct UsageLineSnapshot: Equatable, Comparable, Sendable {
let label: String
let used: Double?
let total: Double?
let percentage: Double?
let unit: String?
let resetDate: Date?
let details: [UsageDetailSnapshot]

init(line: UsageLine) {
label = line.label
used = line.used
total = line.total
percentage = line.percentage
unit = line.unit
resetDate = line.resetDate
details = (line.details ?? []).map(UsageDetailSnapshot.init).sorted()
assert(
Set(observation.metrics.map(\.id)).count == observation.metrics.count,
"Provider activity metric IDs must be unique."
)
metrics = observation.metrics.sorted { $0.id < $1.id }
availability = observation.availability
}

static func < (lhs: Self, rhs: Self) -> Bool {
if lhs.label != rhs.label {
return lhs.label < rhs.label
}
if lhs.used != rhs.used {
return optionalLess(lhs.used, rhs.used)
}
if lhs.total != rhs.total {
return optionalLess(lhs.total, rhs.total)
}
if lhs.percentage != rhs.percentage {
return optionalLess(lhs.percentage, rhs.percentage)
}
if lhs.unit != rhs.unit {
return optionalLess(lhs.unit, rhs.unit)
}
if lhs.resetDate != rhs.resetDate {
return optionalLess(lhs.resetDate, rhs.resetDate)
}
for (leftDetail, rightDetail) in zip(lhs.details, rhs.details) where leftDetail != rightDetail {
return leftDetail < rightDetail
func changeReasons(comparedTo current: Self) -> Set<ChangeReason> {
var reasons: Set<ChangeReason> = []

if !metrics.isEmpty, !current.metrics.isEmpty {
let previousMetrics = Dictionary(
metrics.map { ($0.id, $0) },
uniquingKeysWith: { first, _ in first }
)
let currentMetrics = Dictionary(
current.metrics.map { ($0.id, $0) },
uniquingKeysWith: { first, _ in first }
)
for metricId in Set(previousMetrics.keys).union(currentMetrics.keys) {
switch (previousMetrics[metricId], currentMetrics[metricId]) {
case let (.some(previous), .some(next)) where previous != next:
reasons.formUnion([previous.kind.reason, next.kind.reason])
case let (.some(previous), .none):
reasons.insert(previous.kind.reason)
case let (.none, .some(next)):
reasons.insert(next.kind.reason)
case (.none, .none), (.some, .some):
break
}
}
}
return lhs.details.count < rhs.details.count
}

private static func optionalLess<Value: Comparable>(
_ lhs: Value?,
_ rhs: Value?
) -> Bool {
switch (lhs, rhs) {
case (.none, .some): true
case (.some, .none): false
case let (.some(left), .some(right)): left < right
case (.none, .none): false
if isKnown(availability), isKnown(current.availability), availability != current.availability {
reasons.insert(.availability)
}
return reasons
}
}
}

struct UsageDetailSnapshot: Equatable, Comparable, Sendable {
let label: String
let value: String

init(detail: UsageDetail) {
label = detail.label
value = detail.value
private extension ProviderActivityMetric.Kind {
var reason: SmartRefreshPolicy.ChangeReason {
switch self {
case .usage:
.usage
case .credits:
.credits
}
}
}

static func < (lhs: Self, rhs: Self) -> Bool {
lhs.label == rhs.label ? lhs.value < rhs.value : lhs.label < rhs.label
}
private func isKnown(_ availability: ProviderAvailability?) -> Bool {
switch availability {
case .some(.available), .some(.unavailable):
true
case .none, .some(.unknown):
false
}
}
20 changes: 19 additions & 1 deletion Sources/Providers/ClaudeCode/ClaudeCodeProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,25 @@ public struct ClaudeCodeProvider: AIProvider {
headline: headline,
lines: lines,
lastUpdated: lastUpdated,
isStale: isStale
isStale: isStale,
activityObservation: activityObservation(from: cache.rateLimits)
)
}

private func activityObservation(from rateLimits: RateLimits?) -> ProviderActivityObservation {
let metrics = [
activityMetric(id: "five-hour-usage", window: rateLimits?.fiveHour),
activityMetric(id: "weekly-usage", window: rateLimits?.sevenDay),
].compactMap { $0 }
return ProviderActivityObservation(metrics: metrics)
}

private func activityMetric(id: String, window: Window?) -> ProviderActivityMetric? {
guard let percentage = window?.usedPercentage else { return nil }
return ProviderActivityMetric(
id: id,
kind: .usage,
value: .number(Decimal(percentage))
)
}

Expand Down
Loading
Loading