Skip to content

Commit 230c3f0

Browse files
authored
Merge pull request #81 from pavelhov/codex/opencode-go-usage
feat: OpenCode Go usage, model refresh, and macOS panel layout
2 parents cefcd74 + 15f9571 commit 230c3f0

61 files changed

Lines changed: 2652 additions & 569 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
263 KB
Loading

‎app/Sources/MenuBarCore/PollingCoordinator.swift‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ public actor PollingCoordinator {
4040
/// Attempt time, distinct from success time: a persistently failing quota endpoint
4141
/// must still respect the slower cadence.
4242
private var lastQuotaAttempt: Date?
43+
private var lastUsageAttempt: Date?
4344

4445
public init(client: ProxyClient, endpoint: ProxyEndpoint) {
4546
self.client = client
@@ -174,6 +175,9 @@ public actor PollingCoordinator {
174175
}
175176

176177
if popoverOpen {
178+
let previousOpenCodeGoActivity = Set(snapshot.activity?.activities.compactMap { activity in
179+
activity.provider?.lowercased() == "opencode-go" ? activity.id : nil
180+
} ?? [])
177181
await refreshActivity(cycle: cycle)
178182
guard isCurrent(cycle) else {
179183
refreshInFlight = false
@@ -184,6 +188,12 @@ public actor PollingCoordinator {
184188
// tick would turn a rarely changing endpoint into a two-second poller.
185189
if includeHeavy { await refreshOnOpen(cycle: cycle) }
186190

191+
let currentOpenCodeGoActivity = Set(snapshot.activity?.activities.compactMap { activity in
192+
activity.provider?.lowercased() == "opencode-go" ? activity.id : nil
193+
} ?? [])
194+
let completedOpenCodeGoRequest = snapshot.activityLoaded
195+
&& !previousOpenCodeGoActivity.isSubset(of: currentOpenCodeGoActivity)
196+
187197
// Rate-limit on ATTEMPT, not success, so a persistently failing endpoint is
188198
// not retried on every two-second activity cycle.
189199
let quotaDue = forceQuotaRefresh || (lastQuotaAttempt.map {
@@ -193,6 +203,12 @@ public actor PollingCoordinator {
193203
lastQuotaAttempt = Date()
194204
await refreshQuotas(cycle: cycle, forceRefresh: forceQuotaRefresh)
195205
}
206+
let usageDue = includeHeavy || forceQuotaRefresh || completedOpenCodeGoRequest || (lastUsageAttempt.map {
207+
Date().timeIntervalSince($0) >= Self.heavyInterval
208+
} ?? true)
209+
if usageDue, isCurrent(cycle) {
210+
await refreshOpenCodeGoUsage(cycle: cycle)
211+
}
196212
}
197213

198214
if cycle == generation { publish() }
@@ -292,6 +308,29 @@ public actor PollingCoordinator {
292308
}
293309
}
294310

311+
/// Usage is a completed-request ledger. Fetch on open, on a finished Go
312+
/// request, and at the heavy cadence; never poll it every two seconds.
313+
private func refreshOpenCodeGoUsage(cycle: Int) async {
314+
guard isCurrent(cycle) else { return }
315+
guard snapshot.providersLoaded else { return }
316+
guard snapshot.providers.contains(where: { $0.name.lowercased() == "opencode-go" && $0.isEnabled }) else {
317+
snapshot.openCodeGoModelUsage = []
318+
snapshot.openCodeGoUsageLoaded = false
319+
snapshot.openCodeGoUsageUpdatedAt = nil
320+
return
321+
}
322+
lastUsageAttempt = Date()
323+
guard let usage = try? await client.usageLast30Days(), isCurrent(cycle) else { return }
324+
snapshot.openCodeGoModelUsage = usage.models.filter {
325+
$0.provider.lowercased() == "opencode-go"
326+
&& !$0.model.isEmpty
327+
&& $0.requests >= 0
328+
&& $0.totalTokens >= 0
329+
}
330+
snapshot.openCodeGoUsageLoaded = true
331+
snapshot.openCodeGoUsageUpdatedAt = Date()
332+
}
333+
295334
/// Readiness is an orthogonal observation. Once authenticated startup health has
296335
/// succeeded, a public-probe transport or contract failure means "unavailable"—it
297336
/// must not be allowed to rewrite the live process as stopped or degraded.

‎app/Sources/MenuBarCore/ProxyClient.swift‎

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,28 @@ public actor ProxyClient {
299299
try await authenticatedGet("api/codex-routing")
300300
}
301301

302+
public func codexCatalogStatus() async throws -> CodexCatalogStatus {
303+
try await authenticatedGet("api/codex-catalog/status")
304+
}
305+
306+
/// Explicit panel refresh uses the same non-disruptive sync as `ccx sync`.
307+
/// Catalog gathering can exceed ordinary management mutation timeouts.
308+
public func syncCodexCatalog() async throws -> CodexCatalogSyncOutcome {
309+
let data = try await authenticatedSend(
310+
method: "POST",
311+
path: "api/sync",
312+
body: nil as EmptyBody?,
313+
timeout: 90
314+
)
315+
let response: CodexCatalogSyncResponse
316+
do {
317+
response = try JSONDecoder().decode(CodexCatalogSyncResponse.self, from: data)
318+
} catch {
319+
throw ProxyError.decoding
320+
}
321+
return response.outcome
322+
}
323+
302324
/// Public post-startup readiness. This request intentionally carries no management
303325
/// credential, and accepts the endpoint's contractually meaningful 503 response for
304326
/// `pending` and `failed` observations.
@@ -356,6 +378,13 @@ public actor ProxyClient {
356378
)
357379
}
358380

381+
public func usageLast30Days() async throws -> ProviderUsageEnvelope {
382+
try await authenticatedGet(
383+
"api/usage",
384+
query: [URLQueryItem(name: "range", value: "30d")]
385+
)
386+
}
387+
359388
public func restart() async throws -> RestartAccepted {
360389
let data = try await authenticatedSend(
361390
method: "POST",
@@ -494,7 +523,8 @@ public actor ProxyClient {
494523
method: String,
495524
path: String,
496525
query: [URLQueryItem] = [],
497-
body: Body?
526+
body: Body?,
527+
timeout: TimeInterval? = nil
498528
) async throws -> Data {
499529
// First attempt: fresh descriptors + identity check immediately before the
500530
// credential-bearing request.
@@ -505,7 +535,8 @@ public actor ProxyClient {
505535
method: method,
506536
path: path,
507537
query: query,
508-
body: body
538+
body: body,
539+
timeout: timeout
509540
)
510541
} catch ProxyError.unauthorized {
511542
// Exactly one retry. Rediscovery may pick up a rotated token or replacement
@@ -516,7 +547,8 @@ public actor ProxyClient {
516547
method: method,
517548
path: path,
518549
query: query,
519-
body: body
550+
body: body,
551+
timeout: timeout
520552
)
521553
}
522554
}
@@ -526,7 +558,8 @@ public actor ProxyClient {
526558
method: String,
527559
path: String,
528560
query: [URLQueryItem],
529-
body: Body?
561+
body: Body?,
562+
timeout: TimeInterval?
530563
) async throws -> Data {
531564
guard installation.credential?.isEmpty == false else {
532565
throw ProxyError.authenticationUnavailable
@@ -558,7 +591,7 @@ public actor ProxyClient {
558591
query: query,
559592
body: body,
560593
credential: credential,
561-
timeout: method == "GET" ? 4 : 8
594+
timeout: timeout ?? (method == "GET" ? 4 : 8)
562595
)
563596
}
564597

‎app/Sources/MenuBarCore/ProxyModels.swift‎

Lines changed: 104 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,89 @@ public struct CodexRouteStatus: Decodable, Equatable, Sendable {
148148
}
149149
}
150150

151+
/// Read-only activation observation from `GET /api/codex-catalog/status`.
152+
/// A restart prompt requires both a published catalog and confirmed stale workers;
153+
/// an unknown or pending status must not tell the user a restart will fix it.
154+
public enum CatalogReloadStatus: Equatable, Sendable {
155+
case unknown
156+
case current
157+
case restartRequired(staleWorkerCount: Int)
158+
}
159+
160+
/// Closed result contract from authenticated `POST /api/sync`.
161+
public enum CodexCatalogSyncStatus: String, Decodable, Equatable, Sendable {
162+
case applied
163+
case skipped
164+
case refused
165+
}
166+
167+
public enum CodexCatalogSyncSkipReason: String, Decodable, Equatable, Sendable {
168+
case desiredDisabled = "desired_disabled"
169+
case externalProvider = "external_provider"
170+
}
171+
172+
public struct CodexCatalogSyncResponse: Decodable, Equatable, Sendable {
173+
public let status: CodexCatalogSyncStatus
174+
public let ok: Bool
175+
public let skippedReason: CodexCatalogSyncSkipReason?
176+
public let warning: String?
177+
178+
public var outcome: CodexCatalogSyncOutcome {
179+
guard ok else { return .failed }
180+
switch status {
181+
case .applied:
182+
return .applied(warning: warning)
183+
case .skipped:
184+
guard let skippedReason else { return .failed }
185+
return .skipped(skippedReason)
186+
case .refused:
187+
return .failed
188+
}
189+
}
190+
}
191+
192+
public enum CodexCatalogSyncOutcome: Equatable, Sendable {
193+
case applied(warning: String?)
194+
case skipped(CodexCatalogSyncSkipReason)
195+
case failed
196+
}
197+
198+
public struct CodexCatalogStatus: Decodable, Sendable {
199+
public struct Activation: Decodable, Sendable {
200+
public struct State: Decodable, Sendable {
201+
public let status: String
202+
}
203+
204+
public struct Workers: Decodable, Sendable {
205+
public let status: String
206+
public let staleCount: Int
207+
}
208+
209+
public let schemaVersion: Int
210+
public let catalog: State
211+
public let routing: State
212+
public let workers: Workers
213+
}
214+
215+
public let activation: Activation
216+
217+
public var reloadStatus: CatalogReloadStatus {
218+
guard activation.schemaVersion == 1,
219+
activation.catalog.status == "current",
220+
activation.routing.status == "current"
221+
else { return .unknown }
222+
223+
switch activation.workers.status {
224+
case "reload_required" where activation.workers.staleCount > 0:
225+
return .restartRequired(staleWorkerCount: activation.workers.staleCount)
226+
case "current", "not_running":
227+
return .current
228+
default:
229+
return .unknown
230+
}
231+
}
232+
}
233+
151234
/// `GET /api/startup-health`
152235
public struct StartupHealth: Decodable, Equatable, Sendable {
153236
public let status: String
@@ -265,13 +348,13 @@ public struct QuotaWindow: Decodable, Equatable, Sendable {
265348
public let resetAt: Double?
266349
}
267350

268-
/// A published provider cap paired with observations from this local CodexCommander usage
269-
/// log. This is reference data, not a provider-reported balance or remaining percent.
351+
/// Observations from this local CodexCommander usage log. Older proxy versions may
352+
/// include a published cap, but it is not a provider-reported balance or current limit.
270353
public struct QuotaReferenceWindow: Decodable, Equatable, Sendable {
271354
public let id: String
272355
public let label: String
273356
public let windowSeconds: Double
274-
public let publishedLimitUsd: Double
357+
public let publishedLimitUsd: Double?
275358
public let observedSpendUsd: Double?
276359
public let observedTokens: Int64
277360
public let observedRequests: Int
@@ -381,6 +464,24 @@ public struct ProviderQuotaEnvelope: Decodable, Equatable, Sendable {
381464
public let availability: [ProviderQuotaAvailability]
382465
}
383466

467+
/// Completed local requests in the management usage log. These are observations,
468+
/// not an OpenCode Go account balance or a live token counter.
469+
public struct ProviderModelUsage: Decodable, Equatable, Sendable {
470+
public let provider: String
471+
public let model: String
472+
public let requests: Int
473+
public let measuredRequests: Int
474+
public let totalTokens: Int64
475+
public let inputTokens: Int64
476+
public let outputTokens: Int64
477+
public let estimatedCostUsd: Double?
478+
}
479+
480+
public struct ProviderUsageEnvelope: Decodable, Equatable, Sendable {
481+
public let generatedAt: Double
482+
public let models: [ProviderModelUsage]
483+
}
484+
384485
public enum AgentActivityRole: String, Decodable, Equatable, Sendable {
385486
case primary
386487
case subagent

‎app/Sources/MenuBarCore/ProxySnapshot.swift‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,10 @@ public struct ProxySnapshot: Equatable, Sendable {
125125
public var quotaAvailability: [ProviderQuotaAvailability]
126126
public var activity: AgentActivitySnapshot?
127127
public var providers: [ProviderSummary]
128+
/// Completed OpenCode Go requests over the last 30 days, from /api/usage.
129+
public var openCodeGoModelUsage: [ProviderModelUsage]
130+
public var openCodeGoUsageLoaded: Bool
131+
public var openCodeGoUsageUpdatedAt: Date?
128132
public var lastUpdated: Date?
129133
public var consecutiveFailures: Int
130134
/// Remembered from the last successful health read, so a stopped proxy can still
@@ -149,6 +153,9 @@ public struct ProxySnapshot: Equatable, Sendable {
149153
quotaAvailability: [ProviderQuotaAvailability] = [],
150154
activity: AgentActivitySnapshot? = nil,
151155
providers: [ProviderSummary] = [],
156+
openCodeGoModelUsage: [ProviderModelUsage] = [],
157+
openCodeGoUsageLoaded: Bool = false,
158+
openCodeGoUsageUpdatedAt: Date? = nil,
152159
lastUpdated: Date? = nil,
153160
consecutiveFailures: Int = 0,
154161
lastKnownStartCommand: String? = nil,
@@ -166,6 +173,9 @@ public struct ProxySnapshot: Equatable, Sendable {
166173
self.quotaAvailability = quotaAvailability
167174
self.activity = activity
168175
self.providers = providers
176+
self.openCodeGoModelUsage = openCodeGoModelUsage
177+
self.openCodeGoUsageLoaded = openCodeGoUsageLoaded
178+
self.openCodeGoUsageUpdatedAt = openCodeGoUsageUpdatedAt
169179
self.lastUpdated = lastUpdated
170180
self.consecutiveFailures = consecutiveFailures
171181
self.lastKnownStartCommand = lastKnownStartCommand

0 commit comments

Comments
 (0)