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
2 changes: 1 addition & 1 deletion Sources/ContextPanelCore/BurnRateEstimator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ public enum MainLimitBurnRateEstimator {
return []
}

return summary.limits.compactMap { limit -> BurnSample? in
return summary.liveLimits.compactMap { limit -> BurnSample? in
guard let used = limit.used, let total = limit.limit else { return nil }
return BurnSample(
bucketID: limit.id,
Expand Down
2 changes: 1 addition & 1 deletion Sources/ContextPanelCore/FastModeForecast.swift
Original file line number Diff line number Diff line change
Expand Up @@ -566,7 +566,7 @@ public struct FastModeCapacityPortfolioForecast: Codable, Equatable, Sendable {
guard let bestForecast else { return "OpenAI account needed for fast-mode forecast" }
let guardrail = forecasts.first { $0.window == .fiveHour }
if bestForecast.window == .weekly, let guardrail, guardrail.recommendation == .saveFastMode || guardrail.recommendation == .limited {
return "\(bestForecast.runwayCopy) · 5h guardrail: \(guardrail.runwayCopy)"
return "\(bestForecast.burnRateCopy) · \(bestForecast.runwayCopy) · 5h guardrail: \(guardrail.runwayCopy)"
}
return "\(bestForecast.burnRateCopy) · \(bestForecast.runwayCopy)"
}
Expand Down
81 changes: 72 additions & 9 deletions Sources/ContextPanelCore/MainLimitSummary.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ public struct MainLimitSummary: Codable, Equatable, Identifiable, Sendable {
public let provider: Provider
public let window: MainLimitWindow
public let limits: [UsageLimit]
public let generatedAt: Date
public let providerMainLimits: [UsageLimit]

public var id: String {
"\(provider.rawValue):\(window.rawValue)"
Expand All @@ -87,25 +89,25 @@ public struct MainLimitSummary: Codable, Equatable, Identifiable, Sendable {
}

public var capacityPool: CapacityPool {
CapacityPool(limits: numericLimits)
CapacityPool(limits: liveNumericLimits)
}

public var unit: UsageUnit? {
guard let firstUnit = numericLimits.first?.unit else { return nil }
guard numericLimits.allSatisfy({ $0.unit == firstUnit }) else { return nil }
guard let firstUnit = liveNumericLimits.first?.unit else { return nil }
guard liveNumericLimits.allSatisfy({ $0.unit == firstUnit }) else { return nil }
return firstUnit
}

public var used: Int? {
guard unit != nil else { return nil }
let numericLimits = limits.filter { $0.used != nil && $0.limit != nil }
let numericLimits = liveNumericLimits
guard !numericLimits.isEmpty else { return nil }
return numericLimits.reduce(0) { total, limit in total + (limit.used ?? 0) }
}

public var limit: Int? {
guard unit != nil else { return nil }
let numericLimits = limits.filter { $0.used != nil && $0.limit != nil }
let numericLimits = liveNumericLimits
guard !numericLimits.isEmpty else { return nil }
return numericLimits.reduce(0) { total, limit in total + (limit.limit ?? 0) }
}
Expand Down Expand Up @@ -149,7 +151,7 @@ public struct MainLimitSummary: Codable, Equatable, Identifiable, Sendable {
}

public var resetsAt: Date? {
nextReset(after: Date()) ?? firstKnownReset
nextReset(after: generatedAt) ?? firstKnownReset
}

public var firstKnownReset: Date? {
Expand Down Expand Up @@ -198,15 +200,44 @@ public struct MainLimitSummary: Codable, Equatable, Identifiable, Sendable {
)
}

public init(provider: Provider, window: MainLimitWindow, limits: [UsageLimit]) {
public init(
provider: Provider,
window: MainLimitWindow,
limits: [UsageLimit],
generatedAt: Date = Date(),
providerMainLimits: [UsageLimit]? = nil
) {
self.provider = provider
self.window = window
self.limits = limits
self.generatedAt = generatedAt
self.providerMainLimits = providerMainLimits ?? limits
}

private var numericLimits: [UsageLimit] {
limits.filter { $0.used != nil && $0.limit != nil }
}

public var liveLimits: [UsageLimit] {
limits.filter { limit in
limit.isLiveCapacityBucket(at: generatedAt)
&& !hasExhaustedLongerWindow(for: limit)
}
}

private var liveNumericLimits: [UsageLimit] {
liveLimits.filter { $0.used != nil && $0.limit != nil }
}

private func hasExhaustedLongerWindow(for limit: UsageLimit) -> Bool {
providerMainLimits.contains { candidate in
guard candidate.accountID == limit.accountID else { return false }
guard candidate.id != limit.id else { return false }
guard candidate.status == .limited else { return false }
guard let candidateWindow = candidate.mainLimitWindow else { return false }
return candidateWindow.capacityGateRank > window.capacityGateRank
}
}
}

public extension UsageLimit {
Expand All @@ -227,7 +258,8 @@ public extension UsageLimit {

public extension UsageSnapshot {
var mainLimitSummaries: [MainLimitSummary] {
let grouped = Dictionary(grouping: limits) { limit in
let mainLimits = limits.filter { $0.mainLimitWindow != nil }
let grouped = Dictionary(grouping: mainLimits) { limit in
limit.mainLimitWindow.map { "\(limit.provider.rawValue):\($0.rawValue)" } ?? ""
}

Expand All @@ -238,7 +270,13 @@ public extension UsageSnapshot {
else {
return nil
}
return MainLimitSummary(provider: first.provider, window: window, limits: limits)
return MainLimitSummary(
provider: first.provider,
window: window,
limits: limits,
generatedAt: generatedAt,
providerMainLimits: mainLimits.filter { $0.provider == first.provider }
)
}
.sorted { lhs, rhs in
if lhs.provider != rhs.provider {
Expand All @@ -262,6 +300,31 @@ public extension UsageSnapshot {
}
}

extension MainLimitWindow {
fileprivate var capacityGateRank: Int {
switch self {
case .fiveHour:
0
case .daily:
1
case .weekly:
2
}
}
}

public extension UsageLimit {
func isLiveCapacityBucket(at date: Date) -> Bool {
if status == .failure || status == .stale || status == .unknown {
return false
}
if let resetsAt, resetsAt <= date.addingTimeInterval(-60) {
return false
}
return true
}
}

extension UsageStatus {
fileprivate var summarySortRank: Double {
switch self {
Expand Down
19 changes: 16 additions & 3 deletions Sources/ContextPanelPreview/ContextPanelPreviewApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1823,15 +1823,17 @@ struct MainLimitDetail: View {
}
if summary.provider == .openAI, limit.unit == .percent {
let settings = model.fastModeForecastSettings
let standardBurnRate = model.observedBurnRates[summary.id]?.unitsPerHour
?? settings.defaultStandardBurnRateUnitsPerHour
return FastModeCapacityForecast(
limitID: summary.id,
accountName: limit.accountName,
providerLimits: summary.limits,
now: Date(),
standardBurnRate: settings.defaultStandardBurnRateUnitsPerHour.map {
standardBurnRate: standardBurnRate.map {
BurnRate(mode: .standard, unitsPerHour: $0)
},
fastBurnRate: settings.defaultStandardBurnRateUnitsPerHour.map {
fastBurnRate: standardBurnRate.map {
BurnRate(mode: .fast, unitsPerHour: $0 * settings.fastModeMultiplier)
},
reserveUnits: settings.reserveUnits,
Expand Down Expand Up @@ -2019,7 +2021,18 @@ final class ContextPanelAppModel: ObservableObject {
}

var fastModeForecast: FastModeCapacityPortfolioForecast {
currentSnapshot.fastModeForecast(settings: fastModeForecastSettings)
currentSnapshot.mainLimitSummaries.openAIFastModeCapacityForecast(
observedBurnRates: observedBurnRates,
settings: fastModeForecastSettings
)
}

var observedBurnRates: [String: ObservedBurnRate] {
MainLimitBurnRateEstimator.observedBurnRates(
current: currentSnapshot,
history: refreshService.loadHistory(),
now: Date()
)
}

var lastRefreshText: String {
Expand Down
113 changes: 113 additions & 0 deletions Tests/ContextPanelCoreTests/FastModeForecastTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,48 @@ private let now = Date(timeIntervalSinceReferenceDate: 900_000_000)
#expect(estimate.sampleCount == 8)
}

@Test func observedBurnRateIgnoresAccountsExhaustedByLongerWindow() throws {
let activeReset = now.addingTimeInterval(5 * 3_600)
let weeklyReset = now.addingTimeInterval(7 * 24 * 3_600)
let history = [
storedOpenAIFiveHourWithWeeklyGate(savedAt: now.addingTimeInterval(-2 * 3_600), activeUsed: 1, fullUsed: 10, activeWeeklyUsed: 20, fullWeeklyUsed: 100, activeReset: activeReset, weeklyReset: weeklyReset),
storedOpenAIFiveHourWithWeeklyGate(savedAt: now.addingTimeInterval(-1 * 3_600), activeUsed: 3, fullUsed: 30, activeWeeklyUsed: 21, fullWeeklyUsed: 100, activeReset: activeReset, weeklyReset: weeklyReset),
storedOpenAIFiveHourWithWeeklyGate(savedAt: now, activeUsed: 5, fullUsed: 60, activeWeeklyUsed: 22, fullWeeklyUsed: 100, activeReset: activeReset, weeklyReset: weeklyReset),
]
let current = try #require(history.last?.snapshot)

let estimates = MainLimitBurnRateEstimator.observedBurnRates(current: current, history: history, now: now)
let estimate = try #require(estimates["openai:fiveHour"])

#expect(abs(estimate.unitsPerHour - 2) < 0.0001)
#expect(estimate.sampleCount == 3)
}

@Test func capacityPortfolioDetailKeepsBurnRateWhenFiveHourGuardrailConstrainWeekly() throws {
let weekly = FastModeCapacityForecast(
limitID: "openai:weekly",
accountName: "OpenAI Weekly pool",
providerLimits: [openAILimit(accountName: "Personal", used: 94, limit: 100, resetsInHours: 96, windowLabel: "Weekly")],
now: now,
standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 2),
fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 3),
reserveUnits: 6
)
let fiveHour = FastModeCapacityForecast(
limitID: "openai:fiveHour",
accountName: "OpenAI 5-hour pool",
providerLimits: [openAILimit(accountName: "Personal", used: 98, limit: 100, resetsInHours: 4, windowLabel: "5-hour")],
now: now,
standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 2),
fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 3),
reserveUnits: 6
)
let portfolio = FastModeCapacityPortfolioForecast(forecasts: [weekly, fiveHour])

#expect(portfolio.detailCopy.contains("2%/h observed"))
#expect(portfolio.detailCopy.contains("5h guardrail:"))
}

@Test func observedBurnRateAllowsConsistentlyUnknownResets() throws {
let reset = now.addingTimeInterval(96 * 3_600)
let history = [
Expand All @@ -665,6 +707,77 @@ private let now = Date(timeIntervalSinceReferenceDate: 900_000_000)
#expect(abs(estimate.unitsPerHour - 1) < 0.0001)
}

private func storedOpenAIFiveHourWithWeeklyGate(
savedAt: Date,
activeUsed: Int,
fullUsed: Int,
activeWeeklyUsed: Int,
fullWeeklyUsed: Int,
activeReset: Date,
weeklyReset: Date
) -> StoredUsageSnapshot {
StoredUsageSnapshot(
savedAt: savedAt,
snapshot: UsageSnapshot(
generatedAt: savedAt,
limits: [
UsageLimit(
provider: .openAI,
accountID: "openai-active",
accountName: "Active",
label: "OpenAI 5-hour",
windowLabel: "5-hour",
unit: .percent,
used: activeUsed,
limit: 100,
resetsAt: activeReset,
lastUpdatedAt: savedAt,
confidence: .observed
),
UsageLimit(
provider: .openAI,
accountID: "openai-full",
accountName: "Full",
label: "OpenAI 5-hour",
windowLabel: "5-hour",
unit: .percent,
used: fullUsed,
limit: 100,
resetsAt: activeReset,
lastUpdatedAt: savedAt,
confidence: .observed
),
UsageLimit(
provider: .openAI,
accountID: "openai-active",
accountName: "Active",
label: "OpenAI Weekly",
windowLabel: "Weekly",
unit: .percent,
used: activeWeeklyUsed,
limit: 100,
resetsAt: weeklyReset,
lastUpdatedAt: savedAt,
confidence: .observed
),
UsageLimit(
provider: .openAI,
accountID: "openai-full",
accountName: "Full",
label: "OpenAI Weekly",
windowLabel: "Weekly",
unit: .percent,
used: fullWeeklyUsed,
limit: 100,
resetsAt: weeklyReset,
lastUpdatedAt: savedAt,
confidence: .observed
),
]
)
)
}

@Test func observedBurnRateSkipsIntervalsThatCrossResets() throws {
let oldReset = now.addingTimeInterval(-90 * 60)
let nextReset = now.addingTimeInterval(96 * 3_600)
Expand Down
Loading