forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathFloatingBarUsageLimiter.swift
More file actions
168 lines (154 loc) · 6.52 KB
/
Copy pathFloatingBarUsageLimiter.swift
File metadata and controls
168 lines (154 loc) · 6.52 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
import Foundation
/// Tracks AI chat/query usage and enforces limits using the server-side quota.
///
/// Shared between the floating bar (Ask omi / PTT queries) and the main chat page
/// (ChatProvider.sendMessage). The server endpoint `/v1/users/me/usage-quota` is
/// the single source of truth for the current month's usage and plan limit.
@MainActor
final class FloatingBarUsageLimiter: ObservableObject {
static let shared = FloatingBarUsageLimiter()
/// Local mirror for avoiding needless requests after the device budget is
/// exhausted. The backend independently resolves the subscription and is the
/// spend authority; this cached value can only make the client more permissive.
static func proactiveBudgetMultiplier(defaults: UserDefaults = .standard, now: Date = Date()) -> Int {
switch defaults.string(forKey: .floatingBarCachedPlan) {
case SubscriptionPlanType.architect.rawValue, SubscriptionPlanType.pro.rawValue:
return 4
case SubscriptionPlanType.operator.rawValue:
return 2
case SubscriptionPlanType.unlimited.rawValue:
let grandfatherUntil = defaults.double(forKey: .floatingBarCachedDesktopGrandfatherUntil)
return grandfatherUntil > now.timeIntervalSince1970 ? 2 : 1
default:
return 1
}
}
@Published private(set) var hasPaidPlan: Bool = false
/// Server-reported quota snapshot, plus an optimistic local delta for queries
/// sent since the last server sync.
@Published private(set) var serverQuota: APIClient.ChatUsageQuota?
@Published private(set) var optimisticDelta: Int = 0
init() {
hasPaidPlan =
UserDefaults.standard.string(forKey: .floatingBarCachedPlan)
.map { SubscriptionPlanType(rawValue: $0).hasPaidCapability } ?? false
}
/// Fetch the user's subscription plan and usage quota from the backend.
/// Call on app launch, sign-in, and after checkout completes.
func fetchPlan() async {
do {
let response = try await APIClient.shared.getUserSubscription()
applyPlan(
plan: response.subscription.plan,
status: response.subscription.status,
desktopGrandfatherUntil: response.desktopGrandfatherUntil)
} catch {
log("FloatingBarUsageLimiter: failed to fetch plan: \(error.localizedDescription)")
}
await syncQuota()
}
/// Sync quota from the server, resetting the optimistic delta.
func syncQuota() async {
if let quota = await APIClient.shared.fetchChatUsageQuota() {
applyQuota(quota)
}
}
/// Apply a quota snapshot directly (used by syncQuota and tests).
func applyQuota(_ quota: APIClient.ChatUsageQuota) {
serverQuota = quota
optimisticDelta = 0
}
/// Update cached plan directly from an already-fetched subscription (no extra API call).
func applyPlan(
plan: SubscriptionPlanType,
status: SubscriptionStatusType,
desktopGrandfatherUntil: Int? = nil
) {
hasPaidPlan = plan.hasPaidCapability && status == .active
// A verified active subscription is authoritative over a stale
// trial/usage flag. Neo uses the Free Desktop floor for non-premium
// features, but it is still paid and must never remain blocked from
// audio capture while the app waits for another trial-metadata poll.
if hasPaidPlan {
AppState.current?.isPaywalled = false
UserDefaults.standard.set(false, forKey: .desktopIsPaywalled)
}
if hasPaidPlan, serverQuota?.planType == SubscriptionPlanType.basic.rawValue {
serverQuota = nil
optimisticDelta = 0
}
// Persist only an active entitlement. Caching an inactive paid plan would
// incorrectly restore both paid access and the larger proactive budget on
// the next launch before the subscription refresh completes.
let shouldPreservePlanIdentity: Bool
if case .unknown = plan {
shouldPreservePlanIdentity = true
} else {
shouldPreservePlanIdentity = hasPaidPlan
}
UserDefaults.standard.set(
shouldPreservePlanIdentity ? plan.rawValue : SubscriptionPlanType.basic.rawValue,
forKey: .floatingBarCachedPlan)
if hasPaidPlan, plan == .unlimited, let desktopGrandfatherUntil {
UserDefaults.standard.set(
desktopGrandfatherUntil,
forKey: .floatingBarCachedDesktopGrandfatherUntil)
} else {
UserDefaults.standard.removeObject(forKey: .floatingBarCachedDesktopGrandfatherUntil)
}
}
/// Reset all quota state on sign-out so the next user starts clean.
func reset() {
serverQuota = nil
optimisticDelta = 0
hasPaidPlan = false
UserDefaults.standard.removeObject(forKey: .floatingBarCachedPlan)
UserDefaults.standard.removeObject(forKey: .floatingBarCachedDesktopGrandfatherUntil)
}
var isLimitReached: Bool {
// BYOK users pay their own LLM bill and are never limited. Honor local
// BYOK state so a heartbeat-lagged server quota (allowed=false right
// after activation) can't block chat for a fully-configured BYOK user.
if APIKeyService.isByokActive {
return false
}
guard let quota = serverQuota else {
// No server data yet — allow the query (server will enforce).
return false
}
// An overage plan bills the excess instead of refusing it: the server serves
// the request and `enforce_chat_quota` never raises, so blocking here would
// deny a send the backend would have answered.
if quota.isOveragePlan == true {
return false
}
if quota.allowed {
// Optimistic delta only applies to question-based quotas.
// For cost_usd (Architect/Pro), we can't estimate cost per query
// locally — rely on the server snapshot alone.
guard quota.unit == "questions", let limit = quota.limit else { return false }
return (quota.used + Double(optimisticDelta)) >= limit
}
return true
}
var remainingQueries: Int {
guard let quota = serverQuota else { return .max }
guard quota.unit == "questions", let limit = quota.limit else { return .max }
return max(0, Int(limit - quota.used) - optimisticDelta)
}
/// Human-readable limit text for error messages.
var limitDescription: String {
guard let quota = serverQuota, let limit = quota.limit else {
return "your monthly free message limit"
}
if quota.unit == "cost_usd" {
return String(format: "your $%.0f %@ monthly spend limit", limit, quota.plan)
}
return "\(Int(limit)) \(quota.plan) messages this month"
}
/// Record a query. Call after successfully sending a query from the floating bar
/// OR the main chat page — both surfaces share this pool.
func recordQuery() {
optimisticDelta += 1
}
}