Skip to content

Commit 6f88fc3

Browse files
author
pavelhov
committed
fix(providers): surface quota authentication failures
1 parent d986bad commit 6f88fc3

27 files changed

Lines changed: 971 additions & 119 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import Foundation
2+
3+
/// Presentation-only source identity stamped by `build:macos`.
4+
///
5+
/// Release version and source revision answer different questions: two worktrees can
6+
/// build version 2.10.2 from different commits. Keep the full revision in Info.plist
7+
/// and use this bounded form in diagnostics/tooltips.
8+
public enum BuildProvenance {
9+
public static func shortRevision(_ raw: Any?) -> String? {
10+
guard let value = raw as? String else { return nil }
11+
let dirty = value.hasSuffix("-dirty")
12+
let revision = dirty ? String(value.dropLast("-dirty".count)) : value
13+
guard revision.count == 40,
14+
revision.unicodeScalars.allSatisfy({ scalar in
15+
(48...57).contains(scalar.value) || (65...70).contains(scalar.value)
16+
|| (97...102).contains(scalar.value)
17+
})
18+
else { return nil }
19+
return String(revision.prefix(8)) + (dirty ? "-dirty" : "")
20+
}
21+
}

‎app/Sources/MenuBarCore/PollingCoordinator.swift‎

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ public actor PollingCoordinator {
2727
/// immediately reopening the popover dropped the reopen's refresh entirely: the old
2828
/// cycle exited on its generation guard and the new one had already been rejected.
2929
private var pendingOpenRefresh = false
30+
/// Preserve a user-requested server-cache bypass when it arrives during another
31+
/// cycle; reducing it to a normal pending refresh would make the button lie.
32+
private var pendingQuotaForceRefresh = false
3033
/// A stale-while-revalidate startup-health response is intentionally conservative.
3134
/// Keep the neutral/last-known state while the background probe catches up, but
3235
/// bound retries so a genuinely broken diagnostic eventually remains visible.
@@ -87,9 +90,19 @@ public actor PollingCoordinator {
8790
/// while the provider quota read still respects
8891
/// the 60s interval so reopening the popover repeatedly does not hammer the proxy.
8992
public func refresh(includeHeavy: Bool = false) async {
93+
await performRefresh(includeHeavy: includeHeavy, forceQuotaRefresh: false)
94+
}
95+
96+
/// Internal pairing keeps `forceQuotaRefresh` from being requested without the
97+
/// popover-heavy reads that can actually consume it.
98+
private func performRefresh(
99+
includeHeavy: Bool,
100+
forceQuotaRefresh: Bool
101+
) async {
90102
// Overlapping cycles publish interleaved state and double the request rate.
91103
guard !refreshInFlight else {
92104
if includeHeavy { pendingOpenRefresh = true }
105+
if forceQuotaRefresh { pendingQuotaForceRefresh = true }
93106
return
94107
}
95108
refreshInFlight = true
@@ -154,12 +167,12 @@ public actor PollingCoordinator {
154167

155168
// Rate-limit on ATTEMPT, not success, so a persistently failing endpoint is
156169
// not retried on every two-second activity cycle.
157-
let quotaDue = lastQuotaAttempt.map {
170+
let quotaDue = forceQuotaRefresh || (lastQuotaAttempt.map {
158171
Date().timeIntervalSince($0) >= Self.heavyInterval
159-
} ?? true
172+
} ?? true)
160173
if quotaDue, isCurrent(cycle) {
161174
lastQuotaAttempt = Date()
162-
await refreshQuotas(cycle: cycle)
175+
await refreshQuotas(cycle: cycle, forceRefresh: forceQuotaRefresh)
163176
}
164177
}
165178

@@ -169,20 +182,22 @@ public actor PollingCoordinator {
169182
}
170183

171184
/// User-initiated refresh: activity is immediate and quotas bypass their normal
172-
/// sixty-second cadence exactly once.
185+
/// sixty-second cadence and the proxy's five-minute cache exactly once.
173186
public func forceRefresh() async {
174-
lastQuotaAttempt = nil
175-
await refresh(includeHeavy: true)
187+
await performRefresh(includeHeavy: true, forceQuotaRefresh: true)
176188
}
177189

178190
/// Runs a refresh that arrived while another cycle held the lock.
179191
private func drainPendingRefresh() async {
180192
guard pendingOpenRefresh, popoverOpen else {
181193
pendingOpenRefresh = false
194+
pendingQuotaForceRefresh = false
182195
return
183196
}
197+
let forceQuotaRefresh = pendingQuotaForceRefresh
184198
pendingOpenRefresh = false
185-
await refresh(includeHeavy: true)
199+
pendingQuotaForceRefresh = false
200+
await performRefresh(includeHeavy: true, forceQuotaRefresh: forceQuotaRefresh)
186201
}
187202

188203
/// Reads that are only meaningful while the popover is open.
@@ -219,11 +234,12 @@ public actor PollingCoordinator {
219234

220235
/// The slower provider-quota read. A failed refresh preserves the last successful
221236
/// rows and their upstream freshness timestamps.
222-
private func refreshQuotas(cycle: Int) async {
237+
private func refreshQuotas(cycle: Int, forceRefresh: Bool) async {
223238
guard isCurrent(cycle) else { return }
224-
if let quotas = try? await client.quotas() {
239+
if let envelope = try? await client.quotas(forceRefresh: forceRefresh) {
225240
guard isCurrent(cycle) else { return }
226-
snapshot.quotas = quotas
241+
snapshot.quotas = envelope.reports ?? []
242+
snapshot.quotaAvailability = envelope.availability ?? []
227243
snapshot.quotasLoaded = true
228244
}
229245
}

‎app/Sources/MenuBarCore/ProxyClient.swift‎

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,6 @@ private struct HealthIdentity: Decodable {
7171
let port: Int
7272
}
7373

74-
private struct QuotaEnvelope: Decodable {
75-
let generatedAt: Double?
76-
let reports: [QuotaReport]?
77-
}
78-
7974
private struct EmptyBody: Encodable {}
8075

8176
public struct RestartAccepted: Decodable, Equatable, Sendable {
@@ -169,12 +164,11 @@ public actor ProxyClient {
169164
try await authenticatedGet("api/agent-activity")
170165
}
171166

172-
public func quotas(forceRefresh: Bool = false) async throws -> [QuotaReport] {
173-
let envelope: QuotaEnvelope = try await authenticatedGet(
167+
public func quotas(forceRefresh: Bool = false) async throws -> ProviderQuotaEnvelope {
168+
try await authenticatedGet(
174169
"api/provider-quotas",
175170
query: forceRefresh ? [URLQueryItem(name: "refresh", value: "1")] : []
176171
)
177-
return envelope.reports ?? []
178172
}
179173

180174
public func restart() async throws -> RestartAccepted {

‎app/Sources/MenuBarCore/ProxyModels.swift‎

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,53 @@ public struct QuotaReport: Decodable, Equatable, Sendable {
143143
public let updatedAt: Double?
144144
}
145145

146+
public enum ProviderQuotaAvailabilityState: Equatable, Sendable, Decodable {
147+
case available
148+
case stale
149+
case unavailable
150+
case unknown
151+
152+
public init(from decoder: Decoder) throws {
153+
switch try decoder.singleValueContainer().decode(String.self) {
154+
case "available": self = .available
155+
case "stale": self = .stale
156+
case "unavailable": self = .unavailable
157+
default: self = .unknown
158+
}
159+
}
160+
}
161+
162+
public enum ProviderQuotaUnavailableReason: Equatable, Sendable, Decodable {
163+
case reauthRequired
164+
case localCLIRefreshRequired
165+
case upstreamUnavailable
166+
case unknown
167+
168+
public init(from decoder: Decoder) throws {
169+
switch try decoder.singleValueContainer().decode(String.self) {
170+
case "reauth_required": self = .reauthRequired
171+
case "local_cli_refresh_required": self = .localCLIRefreshRequired
172+
case "upstream_unavailable": self = .upstreamUnavailable
173+
default: self = .unknown
174+
}
175+
}
176+
}
177+
178+
public struct ProviderQuotaAvailability: Decodable, Equatable, Sendable {
179+
public let provider: String
180+
public let status: ProviderQuotaAvailabilityState
181+
public let reason: ProviderQuotaUnavailableReason?
182+
public let checkedAt: Double?
183+
}
184+
185+
/// Additive `/api/provider-quotas` envelope. Older proxies omit `availability`; the
186+
/// companion preserves its existing generic unavailable fallback in that case.
187+
public struct ProviderQuotaEnvelope: Decodable, Equatable, Sendable {
188+
public let generatedAt: Double?
189+
public let reports: [QuotaReport]?
190+
public let availability: [ProviderQuotaAvailability]?
191+
}
192+
146193
public enum AgentActivityRole: String, Decodable, Equatable, Sendable {
147194
case primary
148195
case subagent
@@ -308,28 +355,35 @@ public struct ProviderSummary: Decodable, Equatable, Sendable {
308355
/// Tray-only display state. An unavailable row is structurally incapable of carrying
309356
/// a fabricated percentage, reset, source, or freshness timestamp.
310357
public enum ProviderQuotaRow: Equatable, Sendable {
311-
case available(QuotaReport)
312-
case unavailable(ProviderSummary)
358+
case available(QuotaReport, ProviderQuotaAvailability?)
359+
case unavailable(ProviderSummary, ProviderQuotaAvailability?)
313360

314361
public var provider: String {
315362
switch self {
316-
case .available(let report): return report.provider
317-
case .unavailable(let summary): return summary.name
363+
case .available(let report, _): return report.provider
364+
case .unavailable(let summary, _): return summary.name
318365
}
319366
}
320367

321368
public var label: String? {
322-
if case .available(let report) = self { return report.label }
369+
if case .available(let report, _) = self { return report.label }
323370
return nil
324371
}
325372

326373
public var report: QuotaReport? {
327-
if case .available(let report) = self { return report }
374+
if case .available(let report, _) = self { return report }
328375
return nil
329376
}
330377

378+
public var availability: ProviderQuotaAvailability? {
379+
switch self {
380+
case .available(_, let availability), .unavailable(_, let availability):
381+
return availability
382+
}
383+
}
384+
331385
public var isUnavailable: Bool {
332-
if case .unavailable = self { return true }
386+
if case .unavailable(_, _) = self { return true }
333387
return false
334388
}
335389

@@ -340,7 +394,15 @@ public extension ProxySnapshot {
340394
/// Join one completed provider inventory with the latest completed quota snapshot.
341395
/// Enabled configured providers form the left side; actual reports always win.
342396
var providerQuotaRows: [ProviderQuotaRow] {
343-
guard providersLoaded, quotasLoaded else { return quotas.map(ProviderQuotaRow.available) }
397+
let availabilityByProvider = Dictionary(
398+
quotaAvailability.map { ($0.provider.lowercased(), $0) },
399+
uniquingKeysWith: { first, _ in first }
400+
)
401+
guard providersLoaded, quotasLoaded else {
402+
return quotas.map {
403+
.available($0, availabilityByProvider[$0.provider.lowercased()])
404+
}
405+
}
344406

345407
var reportsByProvider: [String: QuotaReport] = [:]
346408
for report in quotas {
@@ -350,10 +412,11 @@ public extension ProxySnapshot {
350412

351413
var rows: [ProviderQuotaRow] = []
352414
for provider in providers where provider.isEnabled {
415+
let availability = availabilityByProvider[provider.name.lowercased()]
353416
if let report = reportsByProvider.removeValue(forKey: provider.name.lowercased()) {
354-
rows.append(.available(report))
417+
rows.append(.available(report, availability))
355418
} else if provider.supportsQuotaReporting {
356-
rows.append(.unavailable(provider))
419+
rows.append(.unavailable(provider, availability))
357420
}
358421
}
359422
return rows

‎app/Sources/MenuBarCore/ProxySnapshot.swift‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ public struct ProxySnapshot: Equatable, Sendable {
7878
public var state: ProxyState
7979
public var endpoint: ProxyEndpoint
8080
public var quotas: [QuotaReport]
81+
public var quotaAvailability: [ProviderQuotaAvailability]
8182
public var activity: AgentActivitySnapshot?
8283
public var providers: [ProviderSummary]
8384
public var lastUpdated: Date?
@@ -99,6 +100,7 @@ public struct ProxySnapshot: Equatable, Sendable {
99100
state: ProxyState = .loading,
100101
endpoint: ProxyEndpoint,
101102
quotas: [QuotaReport] = [],
103+
quotaAvailability: [ProviderQuotaAvailability] = [],
102104
activity: AgentActivitySnapshot? = nil,
103105
providers: [ProviderSummary] = [],
104106
lastUpdated: Date? = nil,
@@ -113,6 +115,7 @@ public struct ProxySnapshot: Equatable, Sendable {
113115
self.state = state
114116
self.endpoint = endpoint
115117
self.quotas = quotas
118+
self.quotaAvailability = quotaAvailability
116119
self.activity = activity
117120
self.providers = providers
118121
self.lastUpdated = lastUpdated

‎app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,23 @@ enum ModelDecodingSuite {
3535
"""
3636

3737
static func run(_ t: TestRunner) {
38+
t.test("build provenance: formats only stamped git revisions") {
39+
t.equal(
40+
BuildProvenance.shortRevision(
41+
"d986bada3d4c7986b8d127b2d60eabfc81713d25"
42+
),
43+
"d986bada"
44+
)
45+
t.equal(
46+
BuildProvenance.shortRevision(
47+
"d986bada3d4c7986b8d127b2d60eabfc81713d25-dirty"
48+
),
49+
"d986bada-dirty"
50+
)
51+
t.isNil(BuildProvenance.shortRevision("2.10.2"), "release version is not provenance")
52+
t.isNil(BuildProvenance.shortRevision("unknown"), "unknown source")
53+
}
54+
3855
t.test("health: decodes the live startup-health payload") {
3956
let health = try decode(StartupHealth.self, liveHealth)
4057
t.equal(health.status, "at-risk")
@@ -83,6 +100,27 @@ enum ModelDecodingSuite {
83100
t.equal(byProvider["xai"]?.providerLabel, "xAI Grok")
84101
}
85102

103+
t.test("quotas: availability reasons are actionable and forward compatible") {
104+
let envelope = try decode(ProviderQuotaEnvelope.self, """
105+
{"generatedAt":1784915336899,"reports":[],"availability":[
106+
{"provider":"xai","status":"unavailable",
107+
"reason":"local_cli_refresh_required","checkedAt":1784915336899},
108+
{"provider":"future","status":"future-state",
109+
"reason":"future-reason","checkedAt":1784915336899}]}
110+
""")
111+
t.equal(envelope.availability?.count, 2)
112+
t.equal(envelope.availability?.first?.status, .unavailable)
113+
t.equal(envelope.availability?.first?.reason, .localCLIRefreshRequired)
114+
t.equal(envelope.availability?.last?.status, .unknown)
115+
t.equal(envelope.availability?.last?.reason, .unknown)
116+
117+
let oldProxy = try decode(
118+
ProviderQuotaEnvelope.self,
119+
#"{"generatedAt":1,"reports":[]}"#
120+
)
121+
t.isNil(oldProxy.availability, "older proxy fallback")
122+
}
123+
86124
t.test("providers: quota presentation eligibility mirrors supported probes") {
87125
let providers = try decode([ProviderSummary].self, """
88126
[
@@ -115,6 +153,10 @@ enum ModelDecodingSuite {
115153
state: .running(StartupHealth(status: "protected")),
116154
endpoint: .default,
117155
quotas: reports,
156+
quotaAvailability: try decode([ProviderQuotaAvailability].self, """
157+
[{"provider":"openai","status":"unavailable",
158+
"reason":"reauth_required","checkedAt":1}]
159+
"""),
118160
providers: providers,
119161
providersLoaded: true,
120162
quotasLoaded: true
@@ -124,6 +166,10 @@ enum ModelDecodingSuite {
124166
t.equal(rows.filter { $0.provider == "xai" }.count, 1, "actual report wins")
125167
t.equal(rows.first { $0.provider == "xai" }?.isUnavailable, false)
126168
t.equal(rows.first { $0.provider == "openai" }?.isUnavailable, true)
169+
t.equal(
170+
rows.first { $0.provider == "openai" }?.availability?.reason,
171+
.reauthRequired
172+
)
127173
}
128174

129175
t.test("quotas: nested freshness wins and report freshness fills the gap") {

0 commit comments

Comments
 (0)