From c6ffa171690e9a08639f405a9e148225839fccdf Mon Sep 17 00:00:00 2001 From: Tyler Hise Date: Tue, 29 Sep 2026 14:25:42 -0400 Subject: [PATCH 1/6] feat: surface OpenCode Go usage in dashboard and menu bar --- .../MenuBarCore/PollingCoordinator.swift | 39 ++++++ app/Sources/MenuBarCore/ProxyClient.swift | 7 ++ app/Sources/MenuBarCore/ProxyModels.swift | 24 +++- app/Sources/MenuBarCore/ProxySnapshot.swift | 10 ++ .../MenuBarCoreTests/ModelDecodingSuite.swift | 35 +++++- .../MenuBarCoreTests/PollingSuite.swift | 49 ++++++++ app/Sources/MenuBarUI/ProviderListView.swift | 93 +++++++++++++- app/Sources/MenuBarUI/Views.swift | 33 +++-- app/Sources/MenuBarUITests/main.swift | 73 +++++++++-- .../src/content/docs/guides/macos-menu-bar.md | 11 +- .../src/content/docs/guides/providers.md | 18 ++- .../src/content/docs/guides/web-dashboard.md | 6 +- .../provider-workspace/ProviderDetails.tsx | 1 + .../provider-workspace/ProviderOverview.tsx | 53 +++++++- .../ProviderOverviewDashboard.tsx | 14 ++- .../provider-workspace/ProviderUsage.tsx | 38 +----- .../ProviderWorkspaceShell.tsx | 19 ++- gui/src/i18n/de.ts | 14 +-- gui/src/i18n/en.ts | 14 +-- gui/src/i18n/ja.ts | 14 +-- gui/src/i18n/ko.ts | 14 +-- gui/src/i18n/ru.ts | 14 +-- gui/src/i18n/zh.ts | 14 +-- .../pages/dashboard-plan-quota-section.tsx | 40 ++---- gui/src/provider-quota-store.ts | 4 +- .../reference-observation.ts | 39 ++++++ gui/src/provider-workspace/report.ts | 14 +-- .../styles/provider-overview-dashboard.css | 11 +- gui/src/styles/provider-quota.css | 5 - gui/tests/dashboard-plan-quota.test.tsx | 8 +- ...ovider-overview-opencode-go-usage.test.tsx | 117 ++++++++++++++++++ gui/tests/provider-reference-quota.test.ts | 44 ++++++- src/providers/quota.ts | 71 +++++++++-- tests/opencode-go-quota.test.ts | 66 ++++++++-- 34 files changed, 806 insertions(+), 220 deletions(-) create mode 100644 gui/src/provider-workspace/reference-observation.ts create mode 100644 gui/tests/provider-overview-opencode-go-usage.test.tsx diff --git a/app/Sources/MenuBarCore/PollingCoordinator.swift b/app/Sources/MenuBarCore/PollingCoordinator.swift index 0960ad0a55d..f6b3a973309 100644 --- a/app/Sources/MenuBarCore/PollingCoordinator.swift +++ b/app/Sources/MenuBarCore/PollingCoordinator.swift @@ -40,6 +40,7 @@ public actor PollingCoordinator { /// Attempt time, distinct from success time: a persistently failing quota endpoint /// must still respect the slower cadence. private var lastQuotaAttempt: Date? + private var lastUsageAttempt: Date? public init(client: ProxyClient, endpoint: ProxyEndpoint) { self.client = client @@ -174,6 +175,9 @@ public actor PollingCoordinator { } if popoverOpen { + let previousOpenCodeGoActivity = Set(snapshot.activity?.activities.compactMap { activity in + activity.provider?.lowercased() == "opencode-go" ? activity.id : nil + } ?? []) await refreshActivity(cycle: cycle) guard isCurrent(cycle) else { refreshInFlight = false @@ -184,6 +188,12 @@ public actor PollingCoordinator { // tick would turn a rarely changing endpoint into a two-second poller. if includeHeavy { await refreshOnOpen(cycle: cycle) } + let currentOpenCodeGoActivity = Set(snapshot.activity?.activities.compactMap { activity in + activity.provider?.lowercased() == "opencode-go" ? activity.id : nil + } ?? []) + let completedOpenCodeGoRequest = snapshot.activityLoaded + && !previousOpenCodeGoActivity.isSubset(of: currentOpenCodeGoActivity) + // Rate-limit on ATTEMPT, not success, so a persistently failing endpoint is // not retried on every two-second activity cycle. let quotaDue = forceQuotaRefresh || (lastQuotaAttempt.map { @@ -193,6 +203,12 @@ public actor PollingCoordinator { lastQuotaAttempt = Date() await refreshQuotas(cycle: cycle, forceRefresh: forceQuotaRefresh) } + let usageDue = includeHeavy || forceQuotaRefresh || completedOpenCodeGoRequest || (lastUsageAttempt.map { + Date().timeIntervalSince($0) >= Self.heavyInterval + } ?? true) + if usageDue, isCurrent(cycle) { + await refreshOpenCodeGoUsage(cycle: cycle) + } } if cycle == generation { publish() } @@ -292,6 +308,29 @@ public actor PollingCoordinator { } } + /// Usage is a completed-request ledger. Fetch on open, on a finished Go + /// request, and at the heavy cadence; never poll it every two seconds. + private func refreshOpenCodeGoUsage(cycle: Int) async { + guard isCurrent(cycle) else { return } + guard snapshot.providersLoaded else { return } + guard snapshot.providers.contains(where: { $0.name.lowercased() == "opencode-go" && $0.isEnabled }) else { + snapshot.openCodeGoModelUsage = [] + snapshot.openCodeGoUsageLoaded = false + snapshot.openCodeGoUsageUpdatedAt = nil + return + } + lastUsageAttempt = Date() + guard let usage = try? await client.usageLast30Days(), isCurrent(cycle) else { return } + snapshot.openCodeGoModelUsage = usage.models.filter { + $0.provider.lowercased() == "opencode-go" + && !$0.model.isEmpty + && $0.requests >= 0 + && $0.totalTokens >= 0 + } + snapshot.openCodeGoUsageLoaded = true + snapshot.openCodeGoUsageUpdatedAt = Date() + } + /// Readiness is an orthogonal observation. Once authenticated startup health has /// succeeded, a public-probe transport or contract failure means "unavailable"—it /// must not be allowed to rewrite the live process as stopped or degraded. diff --git a/app/Sources/MenuBarCore/ProxyClient.swift b/app/Sources/MenuBarCore/ProxyClient.swift index f1d7db37a8c..4d267163f5b 100644 --- a/app/Sources/MenuBarCore/ProxyClient.swift +++ b/app/Sources/MenuBarCore/ProxyClient.swift @@ -356,6 +356,13 @@ public actor ProxyClient { ) } + public func usageLast30Days() async throws -> ProviderUsageEnvelope { + try await authenticatedGet( + "api/usage", + query: [URLQueryItem(name: "range", value: "30d")] + ) + } + public func restart() async throws -> RestartAccepted { let data = try await authenticatedSend( method: "POST", diff --git a/app/Sources/MenuBarCore/ProxyModels.swift b/app/Sources/MenuBarCore/ProxyModels.swift index bf5e4427959..373b3dff8f7 100644 --- a/app/Sources/MenuBarCore/ProxyModels.swift +++ b/app/Sources/MenuBarCore/ProxyModels.swift @@ -265,13 +265,13 @@ public struct QuotaWindow: Decodable, Equatable, Sendable { public let resetAt: Double? } -/// A published provider cap paired with observations from this local CodexCommander usage -/// log. This is reference data, not a provider-reported balance or remaining percent. +/// Observations from this local CodexCommander usage log. Older proxy versions may +/// include a published cap, but it is not a provider-reported balance or current limit. public struct QuotaReferenceWindow: Decodable, Equatable, Sendable { public let id: String public let label: String public let windowSeconds: Double - public let publishedLimitUsd: Double + public let publishedLimitUsd: Double? public let observedSpendUsd: Double? public let observedTokens: Int64 public let observedRequests: Int @@ -381,6 +381,24 @@ public struct ProviderQuotaEnvelope: Decodable, Equatable, Sendable { public let availability: [ProviderQuotaAvailability] } +/// Completed local requests in the management usage log. These are observations, +/// not an OpenCode Go account balance or a live token counter. +public struct ProviderModelUsage: Decodable, Equatable, Sendable { + public let provider: String + public let model: String + public let requests: Int + public let measuredRequests: Int + public let totalTokens: Int64 + public let inputTokens: Int64 + public let outputTokens: Int64 + public let estimatedCostUsd: Double? +} + +public struct ProviderUsageEnvelope: Decodable, Equatable, Sendable { + public let generatedAt: Double + public let models: [ProviderModelUsage] +} + public enum AgentActivityRole: String, Decodable, Equatable, Sendable { case primary case subagent diff --git a/app/Sources/MenuBarCore/ProxySnapshot.swift b/app/Sources/MenuBarCore/ProxySnapshot.swift index a9d03af3bf8..dc6e8e0a14d 100644 --- a/app/Sources/MenuBarCore/ProxySnapshot.swift +++ b/app/Sources/MenuBarCore/ProxySnapshot.swift @@ -125,6 +125,10 @@ public struct ProxySnapshot: Equatable, Sendable { public var quotaAvailability: [ProviderQuotaAvailability] public var activity: AgentActivitySnapshot? public var providers: [ProviderSummary] + /// Completed OpenCode Go requests over the last 30 days, from /api/usage. + public var openCodeGoModelUsage: [ProviderModelUsage] + public var openCodeGoUsageLoaded: Bool + public var openCodeGoUsageUpdatedAt: Date? public var lastUpdated: Date? public var consecutiveFailures: Int /// Remembered from the last successful health read, so a stopped proxy can still @@ -149,6 +153,9 @@ public struct ProxySnapshot: Equatable, Sendable { quotaAvailability: [ProviderQuotaAvailability] = [], activity: AgentActivitySnapshot? = nil, providers: [ProviderSummary] = [], + openCodeGoModelUsage: [ProviderModelUsage] = [], + openCodeGoUsageLoaded: Bool = false, + openCodeGoUsageUpdatedAt: Date? = nil, lastUpdated: Date? = nil, consecutiveFailures: Int = 0, lastKnownStartCommand: String? = nil, @@ -166,6 +173,9 @@ public struct ProxySnapshot: Equatable, Sendable { self.quotaAvailability = quotaAvailability self.activity = activity self.providers = providers + self.openCodeGoModelUsage = openCodeGoModelUsage + self.openCodeGoUsageLoaded = openCodeGoUsageLoaded + self.openCodeGoUsageUpdatedAt = openCodeGoUsageUpdatedAt self.lastUpdated = lastUpdated self.consecutiveFailures = consecutiveFailures self.lastKnownStartCommand = lastKnownStartCommand diff --git a/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift b/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift index c9e325992c0..6586ebb0c0b 100644 --- a/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift +++ b/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift @@ -280,6 +280,15 @@ enum ModelDecodingSuite { t.equal(report.normalized().percent, 61) } + t.test("quotas: OpenCode Go live usage exposes rolling, weekly, and monthly windows") { + let json = report(provider: "opencode-go", label: "OpenCode Go", quota: #"{"updatedAt":1,"fiveHourPercent":12.5,"fiveHourResetAt":1784928599718,"weeklyPercent":40,"weeklyResetAt":1785265199718,"monthlyPercent":65,"monthlyResetAt":1785542400000}"#) + let quota = try decode(QuotaReport.self, json) + let windows = quota.normalizedWindows() + t.equal(windows.map(\.windowLabel), ["5h", "week", "month"]) + t.equal(windows.map(\.percent), [12.5, 40, 65]) + t.equal(quota.normalized().windowLabel, "month") + } + t.test("quotas: multiple custom windows are all retained") { let json = report(provider: "cursor", label: "Cursor", quota: #"{"updatedAt":1,"monthlyPercent":10,"monthlyResetAt":1785256304000,"customWindows":[{"label":"First-party models","percent":4,"resetAt":1785256304000},{"label":"API usage","percent":1,"resetAt":1785256304000}]}"#) let report = try decode(QuotaReport.self, json) @@ -327,22 +336,22 @@ enum ModelDecodingSuite { t.isNil(normalized.resetAt, "resetAt") } - t.test("quotas: OpenCode Go reference caps decode without inventing a percentage") { + t.test("quotas: OpenCode Go local observations decode without inventing a percentage") { let json = """ {"provider":"opencode-go","label":"OpenCode Go","updatedAt":1784915090763, - "source":"opencode-go:published-caps+local-estimate","quota":{ + "source":"opencode-go:local-observation","quota":{ "updatedAt":1784915090763, "referenceWindows":[ {"id":"five_hour","label":"5-hour","windowSeconds":18000, - "publishedLimitUsd":12,"observedSpendUsd":0.3, + "observedSpendUsd":0.3, "observedTokens":1000120,"observedRequests":3,"pricedRequests":3, "unpricedRequests":0,"unmeasuredRequests":0,"coverage":"complete"}, {"id":"weekly","label":"7-day","windowSeconds":604800, - "publishedLimitUsd":30,"observedSpendUsd":1.1, + "observedSpendUsd":1.1, "observedTokens":2400000,"observedRequests":4,"pricedRequests":2, "unpricedRequests":1,"unmeasuredRequests":1,"coverage":"partial"}, {"id":"monthly","label":"30-day","windowSeconds":2592000, - "publishedLimitUsd":60,"observedTokens":0,"observedRequests":0, + "observedTokens":0,"observedRequests":0, "pricedRequests":0,"unpricedRequests":0,"unmeasuredRequests":0, "coverage":"none"}], "observedLimitEvent":{"limitName":"weekly","observedAt":1784915090763, @@ -350,7 +359,7 @@ enum ModelDecodingSuite { """ let report = try decode(QuotaReport.self, json) t.equal(report.referenceWindows.count, 3) - t.equal(report.referenceWindows.map(\.publishedLimitUsd), [12, 30, 60]) + t.expect(report.referenceWindows.allSatisfy { $0.publishedLimitUsd == nil }, "new observations omit obsolete caps") t.equal(report.referenceWindows.map(\.observationQuality), [.estimate, .partial, .none]) t.equal(report.observedLimitEvent?.limitName, "weekly") @@ -359,6 +368,20 @@ enum ModelDecodingSuite { t.isNil(report.normalized().percent, "reference percent") } + t.test("quotas: older OpenCode Go payloads with caps still decode") { + let json = """ + {"provider":"opencode-go","label":"OpenCode Go","source":"test","updatedAt":1, + "quota":{"updatedAt":1,"referenceWindows":[{ + "id":"five_hour","label":"5-hour","windowSeconds":18000, + "publishedLimitUsd":12,"observedSpendUsd":0.3,"observedTokens":100, + "observedRequests":1,"pricedRequests":1,"unpricedRequests":0, + "unmeasuredRequests":0,"coverage":"complete"}]}} + """ + let report = try decode(QuotaReport.self, json) + t.equal(report.referenceWindows.first?.publishedLimitUsd, 12) + t.equal(report.referenceWindows.first?.observationQuality, .estimate) + } + t.test("quotas: inconsistent complete coverage degrades to Partial") { let json = """ {"provider":"opencode-go","label":"OpenCode Go","source":"test","updatedAt":1,"quota":{"updatedAt":1,"referenceWindows":[{ diff --git a/app/Sources/MenuBarCoreTests/PollingSuite.swift b/app/Sources/MenuBarCoreTests/PollingSuite.swift index 6caf3bf5340..04514acaf92 100644 --- a/app/Sources/MenuBarCoreTests/PollingSuite.swift +++ b/app/Sources/MenuBarCoreTests/PollingSuite.swift @@ -51,6 +51,55 @@ enum PollingSuite { t.equal(sync { await coordinator.current }.activityLoaded, false) } + t.test("polling: OpenCode Go loads completed model usage only while open") { + let activity = """ + {"schemaVersion":1,"generatedAt":1,"proxyState":"active","activeTurnCount":1, + "displayedActivityCount":1,"unattributedActiveCount":0,"truncated":false, + "activities":[{"id":"go","role":"primary","provider":"opencode-go", + "model":"glm-5.2","phase":"running","startedAt":1}]} + """ + let providers = """ + [{"name":"opencode-go","adapter":"openai-chat","authMode":"key", + "hasApiKey":true,"disabled":false,"quotaCapable":true}] + """ + let quotas = """ + {"generatedAt":1,"reports":[{"provider":"opencode-go","label":"OpenCode Go", + "source":"test","updatedAt":1,"quota":{"updatedAt":1}}],"availability":[]} + """ + let usage = """ + {"generatedAt":1,"models":[ + {"provider":"opencode-go","model":"glm-5.2","requests":2, + "measuredRequests":2,"totalTokens":1200,"inputTokens":1000, + "outputTokens":200,"estimatedCostUsd":0.4}, + {"provider":"kimi","model":"k3","requests":1,"measuredRequests":1, + "totalTokens":50,"inputTokens":40,"outputTokens":10}]} + """ + StubProtocol.reset( + startupResponses(startupHealth(status: "protected", diagnosticStale: false)) + + healthResponses(activity) + + healthResponses(providers) + + healthResponses(quotas) + + healthResponses(usage) + ) + let endpoint = ProxyEndpoint(host: "127.0.0.1", port: 10100, expectedPID: 42)! + let client = ProxyClient( + endpoint: endpoint, + session: ProxyClient.secureSessionForTesting(protocolClasses: [StubProtocol.self]), + credentials: StaticCredentialStore("admin-secret"), + attestationSecret: StubProtocol.attestationSecret + ) + let coordinator = PollingCoordinator(client: client, endpoint: endpoint) + sync { await coordinator.setPopoverOpen(true) } + let snapshot = sync { await coordinator.current } + t.equal(snapshot.openCodeGoUsageLoaded, true) + t.equal(snapshot.openCodeGoModelUsage.count, 1) + t.equal(snapshot.openCodeGoModelUsage.first?.model, "glm-5.2") + let request = StubProtocol.recorded.first { $0.url?.path == "/api/usage" } + t.equal(request?.url?.query, "range=30d") + sync { await coordinator.setPopoverOpen(false) } + t.equal(sync { await coordinator.current }.openCodeGoUsageLoaded, true) + } + t.test("polling: connection refusal is stopped; repeated failures back off") { let endpoint = ProxyEndpoint(host: "127.0.0.1", port: 10100, expectedPID: 42)! StubProtocol.reset([ diff --git a/app/Sources/MenuBarUI/ProviderListView.swift b/app/Sources/MenuBarUI/ProviderListView.swift index ec86b213480..6a58140e6aa 100644 --- a/app/Sources/MenuBarUI/ProviderListView.swift +++ b/app/Sources/MenuBarUI/ProviderListView.swift @@ -12,6 +12,10 @@ public final class ProviderQuotaAccordionView: NSView { private var expandedProviders: Set = [] private var didSeedExpansion = false private var currentRows: [ProviderQuotaRow] = [] + private var currentOpenCodeGoModels: [ProviderModelUsage] = [] + private var currentOpenCodeGoUsageLoaded = false + private var currentOpenCodeGoUsageUpdatedAt: Date? + private var currentOpenCodeGoActiveModels: Set = [] public var onManage: ((String) -> Void)? public var onViewAll: (() -> Void)? @@ -68,6 +72,14 @@ public final class ProviderQuotaAccordionView: NSView { public required init?(coder: NSCoder) { nil } public func apply(_ snapshot: ProxySnapshot) { + currentOpenCodeGoModels = snapshot.openCodeGoModelUsage + currentOpenCodeGoUsageLoaded = snapshot.openCodeGoUsageLoaded + currentOpenCodeGoUsageUpdatedAt = snapshot.openCodeGoUsageUpdatedAt + currentOpenCodeGoActiveModels = Set(snapshot.activity?.activities.compactMap { activity in + guard activity.provider?.lowercased() == "opencode-go", + let model = activity.model, !model.isEmpty else { return nil } + return model + } ?? []) for view in rows.arrangedSubviews { rows.removeArrangedSubview(view) view.removeFromSuperview() @@ -111,6 +123,10 @@ public final class ProviderQuotaAccordionView: NSView { let row = ProviderQuotaRowView( row: quotaRow, expanded: expanded, + modelUsage: quotaRow.provider.lowercased() == "opencode-go" ? currentOpenCodeGoModels : [], + modelUsageLoaded: currentOpenCodeGoUsageLoaded, + modelUsageUpdatedAt: currentOpenCodeGoUsageUpdatedAt, + activeModels: currentOpenCodeGoActiveModels, onToggle: { [weak self] in self?.toggle(quotaRow.provider) }, onManage: { [weak self] in self?.onManage?(quotaRow.provider) } ) @@ -169,6 +185,10 @@ public final class ProviderQuotaAccordionView: NSView { let row = ProviderQuotaRowView( row: quotaRow, expanded: expanded, + modelUsage: quotaRow.provider.lowercased() == "opencode-go" ? currentOpenCodeGoModels : [], + modelUsageLoaded: currentOpenCodeGoUsageLoaded, + modelUsageUpdatedAt: currentOpenCodeGoUsageUpdatedAt, + activeModels: currentOpenCodeGoActiveModels, onToggle: { [weak self] in self?.toggle(quotaRow.provider) }, onManage: { [weak self] in self?.onManage?(quotaRow.provider) } ) @@ -196,6 +216,12 @@ public final class ProviderQuotaAccordionView: NSView { .first(where: { $0.providerID == provider })? .accessibilityLabel() } + package func modelUsageTextForTesting(_ provider: String) -> [String] { + rows.arrangedSubviews + .compactMap { $0 as? ProviderQuotaRowView } + .first(where: { $0.providerID == provider })? + .modelUsageText ?? [] + } package func toggleForTesting(_ provider: String) { toggle(provider) } package func triggerViewAllForTesting() { onViewAll?() } package func triggerManageForTesting(_ provider: String) { onManage?(provider) } @@ -209,11 +235,16 @@ public final class ProviderQuotaAccordionView: NSView { } final class ProviderQuotaRowView: NSView { + private(set) var modelUsageText: [String] = [] let providerID: String init( row: ProviderQuotaRow, expanded: Bool, + modelUsage: [ProviderModelUsage], + modelUsageLoaded: Bool, + modelUsageUpdatedAt: Date?, + activeModels: Set, onToggle: @escaping () -> Void, onManage: @escaping () -> Void ) { @@ -245,7 +276,20 @@ final class ProviderQuotaRowView: NSView { chevron.widthAnchor.constraint(equalToConstant: 12).isActive = true chevron.heightAnchor.constraint(equalToConstant: 12).isActive = true - let summary = makeLabel(collapsedSummary(row), font: Theme.micro, color: Theme.faint) + let compactSummary: String + if row.provider.lowercased() == "opencode-go", !activeModels.isEmpty { + let activeSummary = activeModels.count == 1 + ? "Using \(activeModels.sorted()[0])" + : "\(activeModels.count) models in flight" + if let quota = row.report?.normalized(), quota.hasPercent { + compactSummary = "\(activeSummary) · \(quota.windowLabel) \(Format.percent(quota.percent))" + } else { + compactSummary = activeSummary + } + } else { + compactSummary = collapsedSummary(row) + } + let summary = makeLabel(compactSummary, font: Theme.micro, color: Theme.faint) let manage = ActionButton(title: row.isUnavailable ? "SETTINGS" : "PROVIDER", handler: onManage) manage.bezelStyle = .texturedRounded @@ -322,6 +366,49 @@ final class ProviderQuotaRowView: NSView { } } } + if row.provider.lowercased() == "opencode-go" { + let freshness = modelUsageUpdatedAt.map { " · updated \(Format.age($0))" } ?? "" + let heading = makeLabel("Model usage · last 30 days\(freshness)", font: Theme.captionMedium, color: Theme.muted) + arranged.append(heading) + let active = activeModels.sorted() + for model in active { + let text = "In flight · \(model)" + modelUsageText.append(text) + arranged.append(makeLabel(text, font: Theme.captionMedium, color: Theme.green)) + } + if !modelUsageLoaded { + let text = "Completed usage unavailable" + modelUsageText.append(text) + arranged.append(makeLabel(text, font: Theme.caption, color: Theme.muted)) + } else if modelUsage.isEmpty { + let text = "No completed requests in the last 30 days" + modelUsageText.append(text) + arranged.append(makeLabel(text, font: Theme.caption, color: Theme.muted)) + } else { + let ordered = modelUsage.sorted { lhs, rhs in + let leftActive = activeModels.contains(lhs.model) + let rightActive = activeModels.contains(rhs.model) + if leftActive != rightActive { return leftActive } + if lhs.totalTokens != rhs.totalTokens { return lhs.totalTokens > rhs.totalTokens } + return lhs.model < rhs.model + } + for usage in ordered { + let tokens = usage.measuredRequests > 0 + ? Format.count(usage.totalTokens) + (usage.measuredRequests < usage.requests + ? " measured tokens (partial)" : " tokens") + : "tokens unknown" + let requests = "\(usage.requests) completed request\(usage.requests == 1 ? "" : "s")" + let cost = usage.estimatedCostUsd.map { " · estimated \(Format.usdEstimate($0))" } ?? "" + let text = "\(usage.model) · \(requests) · \(tokens)\(cost)" + modelUsageText.append(text) + let label = makeLabel(text, font: Theme.caption, color: Theme.text) + label.lineBreakMode = .byWordWrapping + label.maximumNumberOfLines = 2 + label.preferredMaxLayoutWidth = Theme.width - Theme.gutter * 2 - 20 + arranged.append(label) + } + } + } } let column = NSStackView(views: arranged) @@ -434,8 +521,8 @@ final class ProviderQuotaRowView: NSView { } let references = report.referenceWindows if !references.isEmpty { - let caps = references.prefix(3).map(ReferenceQuotaPresentation.compactCapText) - return "Caps \(caps.joined(separator: " · "))" + let observed = references.first(where: { $0.observationQuality != .none }) ?? references[0] + return "Local usage · \(ReferenceQuotaPresentation.compactObservationText(observed))" } return "Unavailable" } diff --git a/app/Sources/MenuBarUI/Views.swift b/app/Sources/MenuBarUI/Views.swift index 6ed8b2bf634..f16b5e680fa 100644 --- a/app/Sources/MenuBarUI/Views.swift +++ b/app/Sources/MenuBarUI/Views.swift @@ -654,7 +654,7 @@ final class QuotaWindowRowView: NSView { } } -/// Text contract for published caps and local observations. Kept separate from +/// Text contract for local observations. Kept separate from /// `NormalizedQuota`: these values must never acquire a percentage bar unless the /// upstream provider actually supplied one. package enum ReferenceQuotaPresentation { @@ -669,12 +669,23 @@ package enum ReferenceQuotaPresentation { } } - package static func capText(_ window: QuotaReferenceWindow) -> String { - "\(horizon(window)) · Published cap \(Format.usdCap(window.publishedLimitUsd))" + package static func title(_ window: QuotaReferenceWindow) -> String { + "\(horizon(window)) · Local usage" } - package static func compactCapText(_ window: QuotaReferenceWindow) -> String { - "\(Format.usdCap(window.publishedLimitUsd))/\(horizon(window))" + package static func compactObservationText(_ window: QuotaReferenceWindow) -> String { + let horizon = horizon(window) + switch window.observationQuality { + case .none: + return "\(horizon) no local usage" + case .estimate: + return "\(horizon) estimated \(Format.usdEstimate(window.observedSpendUsd))" + case .partial: + if let spend = window.observedSpendUsd { + return "\(horizon) partial estimate \(Format.usdEstimate(spend))" + } + return "\(horizon) partial · \(Format.count(window.observedTokens)) tokens" + } } package static func observationText(_ window: QuotaReferenceWindow) -> String { @@ -719,14 +730,14 @@ package enum ReferenceQuotaPresentation { } } -/// A published cap with local estimate/coverage text. No progress bar is rendered: -/// dividing observed spend by this cap would manufacture provider quota state. +/// Local estimate/coverage text. No progress bar is rendered because local +/// observations cannot establish a provider quota percentage. final class ReferenceQuotaWindowRowView: NSView { init(window: QuotaReferenceWindow) { super.init(frame: .zero) - let cap = makeLabel( - ReferenceQuotaPresentation.capText(window), + let title = makeLabel( + ReferenceQuotaPresentation.title(window), font: Theme.captionMedium, color: Theme.text ) @@ -736,7 +747,7 @@ final class ReferenceQuotaWindowRowView: NSView { color: Theme.muted ) observation.lineBreakMode = .byTruncatingTail - let column = NSStackView(views: [cap, observation]) + let column = NSStackView(views: [title, observation]) column.orientation = .vertical column.alignment = .leading column.spacing = 2 @@ -754,7 +765,7 @@ final class ReferenceQuotaWindowRowView: NSView { setAccessibilityElement(true) setAccessibilityRole(.staticText) setAccessibilityLabel( - "\(ReferenceQuotaPresentation.capText(window)). \(ReferenceQuotaPresentation.observationText(window))." + "\(ReferenceQuotaPresentation.title(window)). \(ReferenceQuotaPresentation.observationText(window))." ) } diff --git a/app/Sources/MenuBarUITests/main.swift b/app/Sources/MenuBarUITests/main.swift index 49688df877a..8b5598485de 100644 --- a/app/Sources/MenuBarUITests/main.swift +++ b/app/Sources/MenuBarUITests/main.swift @@ -143,6 +143,8 @@ func makeSnapshot( quotaAvailability: [ProviderQuotaAvailability] = [], activity: AgentActivitySnapshot? = nil, providers: [ProviderSummary] = [], + openCodeGoModelUsage: [ProviderModelUsage] = [], + openCodeGoUsageLoaded: Bool = false, health: StartupHealth = currentHealth(), readiness: ProxyReadinessState = .unknown, recommendedCommand: String? = nil, @@ -158,6 +160,8 @@ func makeSnapshot( quotaAvailability: quotaAvailability, activity: activity, providers: providers, + openCodeGoModelUsage: openCodeGoModelUsage, + openCodeGoUsageLoaded: openCodeGoUsageLoaded, lastUpdated: Date(), recommendedCommand: recommendedCommand, providersLoaded: providersLoaded, @@ -657,20 +661,20 @@ runner.test("ui: overflowing provider content fits the scroll viewport") { ) } -runner.test("ui: OpenCode Go renders published caps and honest local observation semantics") { +runner.test("ui: OpenCode Go renders local observations without obsolete caps") { let quotas = decodeQuotas(""" [{"provider":"opencode-go","label":"OpenCode Go","source":"test","updatedAt":1,"quota":{"updatedAt":1, "referenceWindows":[ {"id":"five_hour","label":"5-hour","windowSeconds":18000, - "publishedLimitUsd":12,"observedSpendUsd":0.3,"observedTokens":1000120, + "observedSpendUsd":0.3,"observedTokens":1000120, "observedRequests":3,"pricedRequests":3,"unpricedRequests":0, "unmeasuredRequests":0,"coverage":"complete"}, {"id":"weekly","label":"7-day","windowSeconds":604800, - "publishedLimitUsd":30,"observedSpendUsd":1.1,"observedTokens":2400000, + "observedSpendUsd":1.1,"observedTokens":2400000, "observedRequests":4,"pricedRequests":2,"unpricedRequests":1, "unmeasuredRequests":1,"coverage":"partial"}, {"id":"monthly","label":"30-day","windowSeconds":2592000, - "publishedLimitUsd":60,"observedTokens":0,"observedRequests":0, + "observedTokens":0,"observedRequests":0, "pricedRequests":0,"unpricedRequests":0,"unmeasuredRequests":0, "coverage":"none"}], "observedLimitEvent":{"limitName":"weekly","observedAt":1784915000000, @@ -678,9 +682,12 @@ runner.test("ui: OpenCode Go renders published caps and honest local observation """) let report = quotas[0] let references = report.referenceWindows - runner.equal(ReferenceQuotaPresentation.capText(references[0]), "5h · Published cap $12") - runner.equal(ReferenceQuotaPresentation.capText(references[1]), "7d · Published cap $30") - runner.equal(ReferenceQuotaPresentation.capText(references[2]), "30d · Published cap $60") + runner.equal(ReferenceQuotaPresentation.title(references[0]), "5h · Local usage") + runner.equal(ReferenceQuotaPresentation.title(references[1]), "7d · Local usage") + runner.equal(ReferenceQuotaPresentation.title(references[2]), "30d · Local usage") + runner.equal(ReferenceQuotaPresentation.compactObservationText(references[0]), "5h estimated $0.30") + runner.equal(ReferenceQuotaPresentation.compactObservationText(references[1]), "7d partial estimate $1.10") + runner.equal(ReferenceQuotaPresentation.compactObservationText(references[2]), "30d no local usage") runner.equal( ReferenceQuotaPresentation.observationText(references[0]), "Estimate $0.30 · 1,000,120 tokens · 3 requests" @@ -704,9 +711,10 @@ runner.test("ui: OpenCode Go renders published caps and honest local observation ) let rendered = references.flatMap { - [ReferenceQuotaPresentation.capText($0), ReferenceQuotaPresentation.observationText($0)] + [ReferenceQuotaPresentation.title($0), ReferenceQuotaPresentation.observationText($0)] }.joined(separator: " ") runner.expect(!rendered.contains("%"), "reference data must not manufacture a percentage") + runner.expect(!rendered.contains("cap"), "local observations must not claim a current cap") let accordion = ProviderQuotaAccordionView() accordion.apply(makeSnapshot(quotas: quotas)) @@ -714,6 +722,55 @@ runner.test("ui: OpenCode Go renders published caps and honest local observation runner.expect(accordion.expandedProviderIDs.contains("opencode-go"), "reference provider expands") } +runner.test("ui: OpenCode Go ignores obsolete caps in an older proxy payload") { + let quotas = decodeQuotas(""" + [{"provider":"opencode-go","label":"OpenCode Go","source":"test","updatedAt":1, + "quota":{"updatedAt":1,"referenceWindows":[{ + "id":"five_hour","label":"5-hour","windowSeconds":18000, + "publishedLimitUsd":12,"observedSpendUsd":0.3,"observedTokens":100, + "observedRequests":1,"pricedRequests":1,"unpricedRequests":0, + "unmeasuredRequests":0,"coverage":"complete"}]}}] + """) + let window = quotas[0].referenceWindows[0] + runner.equal(window.publishedLimitUsd, 12) + let presentation = "\(ReferenceQuotaPresentation.title(window)) · \(ReferenceQuotaPresentation.observationText(window))" + runner.expect(!presentation.contains("$12"), "legacy caps must not appear as current limits") + runner.expect(presentation.contains("$0.30"), "local estimate remains visible") +} + +runner.test("ui: OpenCode Go shows the active model and completed per-model usage") { + let quotas = decodeQuotas(""" + [{"provider":"opencode-go","label":"OpenCode Go","source":"test","updatedAt":1, + "quota":{"updatedAt":1,"fiveHourPercent":12,"weeklyPercent":40, + "monthlyPercent":65}}] + """) + let usage = try! JSONDecoder().decode(ProviderUsageEnvelope.self, from: Data(""" + {"generatedAt":1784915336899,"models":[ + {"provider":"opencode-go","model":"glm-5.2","requests":3,"measuredRequests":2, + "totalTokens":1200,"inputTokens":1000,"outputTokens":200,"estimatedCostUsd":0.4}, + {"provider":"opencode-go","model":"new-unpriced","requests":1,"measuredRequests":0, + "totalTokens":0,"inputTokens":0,"outputTokens":0}]} + """.utf8)) + let activity = activitySnapshot(activities: """ + {"id":"go","role":"primary","provider":"opencode-go","model":"glm-5.2", + "phase":"running","startedAt":1} + """, activeTurnCount: 1) + let accordion = ProviderQuotaAccordionView() + accordion.apply(makeSnapshot( + quotas: quotas, + activity: activity, + openCodeGoModelUsage: usage.models, + openCodeGoUsageLoaded: true + )) + let text = accordion.modelUsageTextForTesting("opencode-go") + runner.equal(quotas[0].normalizedWindows().count, 3, "live Go quota windows are present") + runner.expect(text.contains("In flight · glm-5.2"), "live model is separate from completed usage") + runner.expect(text.contains("glm-5.2 · 3 completed requests · 1,200 measured tokens (partial) · estimated $0.40"), + "partially measured totals") + runner.expect(text.contains("new-unpriced · 1 completed request · tokens unknown"), + "unmeasured and unpriced usage is not a false zero") +} + // MARK: - Deep links runner.test("ui: provider deep-link encoding preserves safe ids") { diff --git a/docs-site/src/content/docs/guides/macos-menu-bar.md b/docs-site/src/content/docs/guides/macos-menu-bar.md index e22a98400db..bbe7a8d1431 100644 --- a/docs-site/src/content/docs/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/guides/macos-menu-bar.md @@ -158,9 +158,14 @@ override it. persistent Codex agent-lifecycle view, and the companion never invents queued, reviewing, rate-limited, or completed history. - **Provider quotas** — provider-reported 5-hour, weekly, monthly, or provider-specific credit - windows and reset times when available. OpenCode Go instead shows its published caps and local - observations, never an invented live balance. Missing data is shown as unavailable, never as zero - usage or unlimited capacity. + windows and reset times when available. OpenCode Go shows its account-wide rolling, weekly, and + monthly used percentages from its Go usage endpoint. If that endpoint is unavailable, the menu + shows locally observed requests, tokens, and estimated cost without a limit or remaining balance. + Missing data is never shown as zero usage or unlimited capacity. +- **OpenCode Go model usage** — expand the provider to see each model's completed requests and + measured tokens over the last 30 days. An active model is marked **In flight** separately; its + final usage is recorded when the request completes. The dashboard's OpenCode Go Overview and Usage + tab show the same 30-day model totals. These local observations are not a live provider balance. - **Dashboard and Logs** — open the corresponding local dashboard view in your default browser with a one-time launch authorization for full dashboard changes, including catalog Apply. - **Startup options…** — opens the dashboard's Startup page when an optional startup upgrade or diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 15f2c0430e9..96cf31f6c3a 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -290,11 +290,19 @@ Volcengine Agent Plan uses its native Responses endpoint through `openai-respons OpenCode authentication store or Keychain for this key. The public model catalog is not evidence that a key works, so a saved key is **unverified** until the -first successful inference using that active key. The provider's published caps are reference caps — -**$12 / 5 hours**, **$30 / 7 days**, and **$60 / 30 days**. The dashboard's matching 5-hour, 7-day, -and 30-day observations are local usage estimates, not live remaining quota or billing. An -authoritative limit event is shown only after the upstream reports a concrete limit event (and, when -provided, its reset). +first successful inference using that active key. With a configured Go key, CodexCommander reads +OpenCode's Go usage endpoint and shows its account-wide rolling, weekly, and monthly **used +percentages** and reset times in the dashboard and macOS menu. When the endpoint is unavailable, +the quota view shows only traffic observed through this proxy in the corresponding time windows. +Those local request, token, and estimated-cost totals are not the provider's current balance or +per-model allowance. A concrete upstream limit event can also be shown with its reported reset. + +OpenCode Go's **Overview** in the dashboard also shows completed requests and tokens for each model +over the last 30 days; the **Usage** tab retains the full model breakdown. Expanding OpenCode Go in +the macOS menu shows the same per-model completed usage beside the provider quota. While +a request is active, the menu identifies its model as **In flight**; its final token count appears +only after the request completes and the proxy records it. Unmeasured token totals are shown as +unknown rather than zero, and estimated cost appears only when pricing is available for that model. The provider pins 28 verified Zen Go model ids, including `deepseek-v4.1-flash`, `glm-5.3-flash`, and `qwen3.8-flash`, as diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 6c34d401c8c..b8c23c4ee67 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -91,9 +91,9 @@ on. **Logs** works the same way with `#logs` and `#logs/debug`. Cost values in **Dashboard**, **Logs**, and **Usage** are API list-price equivalents calculated from reported tokens. They are not billing receipts or evidence of an actual charge; subscription usage or provider credits may apply instead. The Dashboard's **Plan & quota** section shows -provider-reported limits (5-hour / weekly / monthly windows), the provider plan, and observed -reference spend versus published caps — always labeled as provider-reported estimates, never billed -spend. +provider-reported limits (5-hour / weekly / monthly windows) and the provider plan when available. +If OpenCode Go's live meter is unavailable, it shows only locally observed traffic and estimated +cost, without a claimed cap or remaining balance. Estimated cost is not billed spend. ### Request usage and dispatch evidence diff --git a/gui/src/components/provider-workspace/ProviderDetails.tsx b/gui/src/components/provider-workspace/ProviderDetails.tsx index f5f6b9894a4..eda2e6eb3f3 100644 --- a/gui/src/components/provider-workspace/ProviderDetails.tsx +++ b/gui/src/components/provider-workspace/ProviderDetails.tsx @@ -263,6 +263,7 @@ export default function ProviderDetails({ apiBase={apiBase} connectionIdentity={connectionIdentity} usageTotals={usageTotals} + modelUsage={modelUsage} quotaReport={quotaReport} quotaUnavailableReason={quotaUnavailableReason} onRetryQuota={onRetryQuota} diff --git a/gui/src/components/provider-workspace/ProviderOverview.tsx b/gui/src/components/provider-workspace/ProviderOverview.tsx index e0f38525dfa..096c5714e1a 100644 --- a/gui/src/components/provider-workspace/ProviderOverview.tsx +++ b/gui/src/components/provider-workspace/ProviderOverview.tsx @@ -10,7 +10,7 @@ import { binProviderStatus, type WorkspaceItem } from "../../provider-workspace/ import { formatRelativeTime, relativeTimeLabelsFromT, formatRequestCount, formatTokenCount } from "../../provider-workspace/usage"; import { accountQuotaFromReport, formatQuotaSourceLabel, type ProviderQuotaReportView } from "../../provider-workspace/report"; import { quotaUnavailableReasonKey } from "../../quota-unavailable"; -import type { ProviderUsageTotals } from "./types"; +import type { ProviderModelUsageRow, ProviderUsageTotals } from "./types"; import { authModeLabel } from "./ProviderRail"; import type { ProviderUpdatePatch } from "./types"; import { ProviderCapacityQuota } from "./ProviderCapacityQuota"; @@ -31,7 +31,7 @@ type ConnectionTestState = { }; export default function ProviderOverview({ - item, usageTotals, quotaReport, quotaUnavailableReason, onRetryQuota, oauthEmail, oauth, + item, usageTotals, modelUsage, quotaReport, quotaUnavailableReason, onRetryQuota, oauthEmail, oauth, accountNeedsReauth = false, apiBase, connectionIdentity, onEditSettings, onViewUsage, onUpdateProvider, @@ -39,6 +39,7 @@ export default function ProviderOverview({ }: { item: WorkspaceItem; usageTotals?: ProviderUsageTotals; + modelUsage?: ProviderModelUsageRow[]; quotaReport?: ProviderQuotaReportView; /** Provider quota is unavailable (no report); reason drives the notice copy. */ quotaUnavailableReason?: string; @@ -86,6 +87,9 @@ export default function ProviderOverview({ : t("prov.disabledBadge"); const requests = usageTotals?.requests; const tokens = usageTotals?.totalTokens; + const openCodeGoModels = item.name === "opencode-go" + ? (modelUsage ?? []).toSorted((a, b) => b.totalTokens - a.totalTokens) + : []; const quota = quotaReport ? accountQuotaFromReport(quotaReport) : null; const connectionProbeKey = JSON.stringify([ apiBase ?? null, @@ -246,6 +250,51 @@ export default function ProviderOverview({ ) : null} + {item.name === "opencode-go" && ( +
+

{t("pws.usageLast30d")}

+ {typeof requests === "number" && ( +
+
+ {formatRequestCount(requests, locale)} + {t("pws.metricRequests")} +
+ {typeof tokens === "number" && ( +
+ {formatTokenCount(tokens, locale)} + {t("pws.metricTokens")} +
+ )} +
+ )} + {openCodeGoModels.length > 0 ? ( +
+ + + + + + + + + + + {openCodeGoModels.map(row => ( + + + + + + ))} + +
{t("pws.modelBreakdown")}
{t("pws.col.model")}{t("pws.col.requests")}{t("pws.col.tokens")}
{row.model}{formatRequestCount(row.requests, locale)}{formatTokenCount(row.totalTokens, locale)}
+
+ ) : typeof requests !== "number" ? ( +

{t("pws.usageUnavailable")}

+ ) : null} +
+ )} +

{t("pws.authSummary")}

{showAuthWarning ? ( diff --git a/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx b/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx index ad95ac5df32..dddbd82d67d 100644 --- a/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx +++ b/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx @@ -27,6 +27,7 @@ import { ProviderIcon } from "./ProviderRail"; import { formatProviderDisplayName } from "../../provider-icons"; import QuotaBars from "../QuotaBars"; import { ProviderCapacityQuota } from "./ProviderCapacityQuota"; +import { referenceCoverageLabel, referenceObservedLabels } from "../../provider-workspace/reference-observation"; export default function ProviderOverviewDashboard({ sections, @@ -181,15 +182,16 @@ export default function ProviderOverviewDashboard({