diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index f367527c4d..104fd26cff 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -255,7 +255,7 @@ extension UsageMenuCardView.Model { { return Self.poeInlineDashboard(usage, now: input.now) } - if [.codex, .claude, .vertexai, .bedrock, .cursor].contains(input.provider), + if [.codex, .claude, .vertexai, .bedrock, .cursor, .opencodego].contains(input.provider), input.tokenCostInlineDashboardEnabled, let tokenSnapshot = input.tokenSnapshot, !tokenSnapshot.daily.isEmpty || tokenSnapshot.meteredCostUSD != nil diff --git a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift index b704754af5..d6ec4e6a70 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift @@ -258,6 +258,10 @@ struct PlanUtilizationHistoryChartMenuView: View { { names.insert(.opus) } + case .opencodego: + if snapshot.primary != nil { names.insert(.session) } + if snapshot.secondary != nil { names.insert(.weekly) } + if snapshot.tertiary != nil { names.insert(.monthly) } default: let windows = [snapshot.primary, snapshot.secondary, snapshot.tertiary].compactMap(\.self) + (snapshot.extraRateWindows?.filter(\.usageKnown).map(\.window) ?? []) @@ -621,6 +625,8 @@ struct PlanUtilizationHistoryChartMenuView: View { L(metadata?.sessionLabel ?? "Session") case .weekly: L(metadata?.weeklyLabel ?? "Weekly") + case .monthly: + metadata?.opusLabel ?? "Monthly" case .opus: metadata?.opusLabel ?? "Opus" default: @@ -641,6 +647,8 @@ struct PlanUtilizationHistoryChartMenuView: View { 0 case .weekly: 1 + case .monthly: + 2 case .opus: 2 default: diff --git a/Sources/CodexBar/PlanUtilizationHistoryStore.swift b/Sources/CodexBar/PlanUtilizationHistoryStore.swift index 5b453217d5..28dc6dff4c 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryStore.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryStore.swift @@ -14,6 +14,7 @@ struct PlanUtilizationSeriesName: RawRepresentable, Hashable, Codable, Expressib static let session: Self = "session" static let weekly: Self = "weekly" + static let monthly: Self = "monthly" static let opus: Self = "opus" func canonicalWindowMinutes(_ windowMinutes: Int) -> Int { diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index e83981879a..3fe5289c3d 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -652,7 +652,7 @@ extension StatusItemController { guard let model = self.menuCardModel(for: context.selectedProvider) else { return false } let renderedModel = self.menuCardRefreshMonitor.model(for: model.provider, fallback: model) if context.openAIContext.hasOpenAIWebMenuItems || - self.hasProviderNativeCostHistorySubmenu(provider: context.currentProvider) + self.requiresSectionedMenuForProviderDerivedCost(provider: context.currentProvider) { let webItems = OpenAIWebMenuItems( hasUsageBreakdown: context.openAIContext.hasUsageBreakdown, @@ -1476,7 +1476,11 @@ extension StatusItemController { if provider == .openai { return self.makeOpenAIAPIUsageSubmenu(provider: provider, width: width) } - if UsageStore.tokenCostRequiresProviderSnapshot(provider) { + // Mistral's top usage pane has no rate-limit bars of its own, so its cost history hangs + // off this row instead. Other `tokenCostRequiresProviderSnapshot` providers (e.g. + // opencodego) show real rate-limit bars here and get their own "Cost" row instead + // (see `makeCostMenuCardItem`), matching Codex/Claude's structure. + if provider == .mistral { return self.makeCostHistorySubmenu(provider: provider, width: width) } if provider == .zai { @@ -1573,7 +1577,14 @@ extension StatusItemController { provider == .openai && self.tokenSnapshotForCostHistorySubmenu(provider: provider)?.daily.isEmpty == false } - private func hasProviderNativeCostHistorySubmenu(provider: UsageProvider) -> Bool { + /// Unlike `makeUsageSubmenu`'s and `tokenCostMenuSectionEnabled`'s provider checks, this one + /// intentionally reuses `tokenCostRequiresProviderSnapshot`: any provider whose cost is + /// sourced by projecting a snapshot field (rather than the CostUsageFetcher pipeline) can only + /// render that cost through `addMenuCardSections`'s sectioned layout, so the two concepts are + /// genuinely coupled here, not coincidentally aliased. The name is deliberately broader than + /// "top-pane submenu" — opencodego satisfies this via its collapsible "Cost" row, not a + /// provider-native top-pane submenu like openai/mistral. + private func requiresSectionedMenuForProviderDerivedCost(provider: UsageProvider) -> Bool { UsageStore.tokenCostRequiresProviderSnapshot(provider) && self.tokenSnapshotForCostHistorySubmenu(provider: provider)?.daily.isEmpty == false } diff --git a/Sources/CodexBar/StatusItemController+MenuCardModel.swift b/Sources/CodexBar/StatusItemController+MenuCardModel.swift index 7cfe7cb15d..d58683c2f5 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardModel.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardModel.swift @@ -161,7 +161,15 @@ extension StatusItemController { tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: target), codexLocalSessionCostLedgerEnabled: self.settings.codexLocalSessionCostLedgerEnabled, tokenCostInlineDashboardEnabled: self.settings.costSummaryShowsInlineDashboard(for: target), - tokenCostMenuSectionEnabled: !UsageStore.tokenCostRequiresProviderSnapshot(target) && + // openai/mistral's cost history always surfaces via the inline dashboard or a + // dedicated top-pane submenu (see `makeUsageSubmenu`), so they skip the generic + // "Cost" row. This must stay an explicit provider check rather than reusing + // `usesProviderCostHistoryAsPrimaryDashboard` (or `tokenCostRequiresProviderSnapshot`): + // both of those sets are shared with unrelated concerns (inline-dashboard eligibility, + // provider-derived snapshot sourcing) and gain members for reasons that have nothing to + // do with whether this row should show, silently disabling the Cost row for those + // providers too (e.g. groq's addition to the inline-dashboard set previously did this). + tokenCostMenuSectionEnabled: target != .mistral && target != .openai && self.settings.costSummaryShowsSubmenu(for: target), costComparisonPeriodsEnabled: self.settings.costComparisonPeriodsEnabled, showOptionalCreditsAndExtraUsage: self.settings.showOptionalCreditsAndExtraUsage, diff --git a/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift b/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift index beadd46c51..91064b356f 100644 --- a/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift @@ -17,7 +17,11 @@ extension StatusItemController { { return submenu } - if UsageStore.tokenCostRequiresProviderSnapshot(provider), + // Mistral's top usage pane has no rate-limit bars of its own, so its Overview row always + // prioritizes cost history too. Other `tokenCostRequiresProviderSnapshot` providers (e.g. + // opencodego) show real rate-limit bars and should fall through to the settings-gated + // check below, same as Codex/Claude (see StatusItemController+Menu.swift's makeUsageSubmenu). + if provider == .mistral, let submenu = self.makeCostHistorySubmenu(provider: provider, width: width) { return submenu diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 2c83e848f6..75d26ce540 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -14,7 +14,7 @@ extension UsageStore { func supportsPlanUtilizationHistory(for provider: UsageProvider) -> Bool { switch provider { - case .codex, .claude, .antigravity: + case .codex, .claude, .antigravity, .opencodego: true default: if self.planUtilizationHistory[provider]?.isEmpty == false { @@ -338,7 +338,7 @@ extension UsageStore { private func shouldRecordPlanUtilizationHistory(for provider: UsageProvider) -> Bool { switch provider { - case .codex, .claude, .antigravity: + case .codex, .claude, .antigravity, .opencodego: true default: self.settings.historicalTrackingEnabled @@ -578,6 +578,10 @@ extension UsageStore { appendWindow(snapshot.primary, name: .session) appendWindow(snapshot.secondary, name: .weekly) appendWindow(snapshot.tertiary, name: .opus) + case .opencodego: + appendWindow(snapshot.primary, name: .session) + appendWindow(snapshot.secondary, name: .weekly) + appendWindow(snapshot.tertiary, name: .monthly) case .antigravity: if forSessionEquivalents { guard let windows = self.sessionEquivalentWindows(provider: provider, snapshot: snapshot) else { diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index d31246d785..a55a14e72c 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -403,6 +403,14 @@ extension UsageStore { snapshot?.openAIAPIUsage?.toCostUsageTokenSnapshot() case .mistral: snapshot?.mistralUsage?.toCostUsageTokenSnapshot(historyDays: self.settings.costUsageHistoryDays) + case .opencodego: + // Web-only source mode and machines with no readable local database leave + // `opencodegoUsage.daily` empty; a non-nil-but-dataless projection would still + // surface a Cost row whose history submenu has nothing to render. + snapshot?.opencodegoUsage.flatMap { usage in + usage.daily.isEmpty ? nil : usage + .toCostUsageTokenSnapshot(historyDays: self.settings.costUsageHistoryDays) + } default: nil } @@ -410,7 +418,7 @@ extension UsageStore { nonisolated static func tokenCostRequiresProviderSnapshot(_ provider: UsageProvider) -> Bool { switch provider { - case .mistral, .openai: + case .mistral, .openai, .opencodego: true default: false diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift index e4cdf237e3..b9282fd313 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift @@ -46,7 +46,7 @@ public struct OpenCodeGoLocalUsageReader: Sendable { self.databaseURL = databaseURL } - public func fetch(now: Date = Date()) throws -> OpenCodeGoUsageSnapshot { + public func fetch(now: Date = Date(), historyDays: Int = 30) throws -> OpenCodeGoUsageSnapshot { let hasAuth = Self.hasAuthKey(at: self.authURL) guard FileManager.default.fileExists(atPath: self.databaseURL.path) else { if hasAuth { @@ -62,7 +62,7 @@ public struct OpenCodeGoLocalUsageReader: Sendable { guard !rows.isEmpty else { throw OpenCodeGoLocalUsageError.historyUnavailable("no local usage rows") } - return Self.snapshot(rows: rows, now: now) + return Self.snapshot(rows: rows, now: now, historyDays: historyDays) } private func readRows() throws -> [UsageRow] { @@ -87,7 +87,9 @@ public struct OpenCodeGoLocalUsageReader: Sendable { var rows: [UsageRow] = [] while true { let step = sqlite3_step(stmt) - if step == SQLITE_DONE { break } + if step == SQLITE_DONE { + break + } guard step == SQLITE_ROW else { let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" throw OpenCodeGoLocalUsageError.sqliteFailed(message) @@ -96,7 +98,8 @@ public struct OpenCodeGoLocalUsageReader: Sendable { let createdMs = sqlite3_column_int64(stmt, 0) let cost = sqlite3_column_double(stmt, 1) guard createdMs > 0, cost >= 0, cost.isFinite else { continue } - rows.append(UsageRow(createdMs: createdMs, cost: cost)) + let requestCount = max(1, Int(sqlite3_column_int64(stmt, 2))) + rows.append(UsageRow(createdMs: createdMs, cost: cost, requestCount: requestCount)) } return rows } @@ -122,7 +125,8 @@ public struct OpenCodeGoLocalUsageReader: Sendable { private static let messageUsageSQL = """ SELECT CAST(COALESCE(json_extract(data, '$.time.created'), time_created) AS INTEGER) AS createdMs, - CAST(json_extract(data, '$.cost') AS REAL) AS cost + CAST(json_extract(data, '$.cost') AS REAL) AS cost, + 1 AS requestCount FROM message WHERE json_valid(data) AND json_extract(data, '$.providerID') = 'opencode-go' @@ -131,42 +135,46 @@ public struct OpenCodeGoLocalUsageReader: Sendable { """ private static let messageAndPartUsageSQL = """ - WITH message_costs AS ( + WITH provider_messages AS ( SELECT id AS messageID, CAST(COALESCE(json_extract(data, '$.time.created'), time_created) AS INTEGER) AS createdMs, - CAST(json_extract(data, '$.cost') AS REAL) AS cost + CAST(json_extract(data, '$.cost') AS REAL) AS cost, + json_type(data, '$.cost') IN ('integer', 'real') AS hasCost FROM message WHERE json_valid(data) AND json_extract(data, '$.providerID') = 'opencode-go' AND json_extract(data, '$.role') = 'assistant' - AND json_type(data, '$.cost') IN ('integer', 'real') ) - SELECT createdMs, cost - FROM message_costs - UNION ALL SELECT - CAST(COALESCE(json_extract(p.data, '$.time.created'), p.time_created, m.time_created) AS INTEGER) + CAST(COALESCE(json_extract(p.data, '$.time.created'), p.time_created, m.createdMs) AS INTEGER) AS createdMs, - CAST(json_extract(p.data, '$.cost') AS REAL) AS cost + CAST(json_extract(p.data, '$.cost') AS REAL) AS cost, + 1 AS requestCount FROM part p - JOIN message m ON m.id = p.message_id + JOIN provider_messages m ON m.messageID = p.message_id WHERE json_valid(p.data) - AND json_valid(m.data) AND json_extract(p.data, '$.type') = 'step-finish' AND json_type(p.data, '$.cost') IN ('integer', 'real') - AND json_extract(m.data, '$.providerID') = 'opencode-go' - AND json_extract(m.data, '$.role') = 'assistant' + UNION ALL + SELECT createdMs, cost, 1 AS requestCount + FROM provider_messages m + WHERE hasCost AND NOT EXISTS ( SELECT 1 - FROM message_costs - WHERE message_costs.messageID = p.message_id + FROM part p + WHERE p.message_id = m.messageID + AND json_valid(p.data) + AND json_extract(p.data, '$.type') = 'step-finish' + AND json_type(p.data, '$.cost') IN ('integer', 'real') ) """ private struct UsageRow { let createdMs: Int64 let cost: Double + /// One provider invocation per step-finish part; message-only databases fall back to one. + let requestCount: Int } private static func hasAuthKey(at url: URL) -> Bool { @@ -180,7 +188,7 @@ public struct OpenCodeGoLocalUsageReader: Sendable { return !key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - private static func snapshot(rows: [UsageRow], now: Date) -> OpenCodeGoUsageSnapshot { + private static func snapshot(rows: [UsageRow], now: Date, historyDays: Int) -> OpenCodeGoUsageSnapshot { let nowMs = Int64(now.timeIntervalSince1970 * 1000) let sessionStart = nowMs - Int64(Self.fiveHours * 1000) let weekStart = self.startOfUTCWeek(now: now).timeIntervalSince1970 * 1000 @@ -189,25 +197,103 @@ public struct OpenCodeGoLocalUsageReader: Sendable { let earliestMs = rows.map(\.createdMs).min() let monthBounds = self.monthBounds(now: now, anchorMs: earliestMs) - let sessionCost = self.sum(rows: rows, startMs: sessionStart, endMs: nowMs) - let weeklyCost = self.sum(rows: rows, startMs: weekStartMs, endMs: weekEndMs) - let monthlyCost = self.sum(rows: rows, startMs: monthBounds.startMs, endMs: monthBounds.endMs) + // Single pass over `rows` for all three window sums plus the oldest-in-session timestamp, + // rather than four separate full scans (one per window plus one for the reset countdown). + let windows = RowAggregateWindows( + sessionStartMs: sessionStart, + nowMs: nowMs, + weekStartMs: weekStartMs, + weekEndMs: weekEndMs, + monthStartMs: monthBounds.startMs, + monthEndMs: monthBounds.endMs) + let aggregates = self.aggregate(rows: rows, windows: windows) + let oldestSessionMs = aggregates.oldestSessionMs ?? nowMs + let rollingResetInSec = max(0, Int((oldestSessionMs + Int64(Self.fiveHours * 1000) - nowMs) / 1000)) return OpenCodeGoUsageSnapshot( hasMonthlyUsage: true, - rollingUsagePercent: self.percent(used: sessionCost, limit: self.limits.session), - weeklyUsagePercent: self.percent(used: weeklyCost, limit: self.limits.weekly), - monthlyUsagePercent: self.percent(used: monthlyCost, limit: self.limits.monthly), - rollingResetInSec: self.rollingReset(rows: rows, nowMs: nowMs), + rollingUsagePercent: self.percent(used: aggregates.sessionCost, limit: self.limits.session), + weeklyUsagePercent: self.percent(used: aggregates.weeklyCost, limit: self.limits.weekly), + monthlyUsagePercent: self.percent(used: aggregates.monthlyCost, limit: self.limits.monthly), + rollingResetInSec: rollingResetInSec, weeklyResetInSec: max(0, Int((weekEndMs - nowMs) / 1000)), monthlyResetInSec: max(0, Int((monthBounds.endMs - nowMs) / 1000)), + daily: self.dailyEntries(rows: rows, now: now, historyDays: historyDays), updatedAt: now) } - private static func sum(rows: [UsageRow], startMs: Int64, endMs: Int64) -> Double { - rows.reduce(0) { total, row in - guard row.createdMs >= startMs, row.createdMs < endMs else { return total } - return total + row.cost + private struct RowAggregateWindows { + let sessionStartMs: Int64 + let nowMs: Int64 + let weekStartMs: Int64 + let weekEndMs: Int64 + let monthStartMs: Int64 + let monthEndMs: Int64 + } + + private struct RowAggregates { + var sessionCost: Double = 0 + var weeklyCost: Double = 0 + var monthlyCost: Double = 0 + var oldestSessionMs: Int64? + } + + private static func aggregate(rows: [UsageRow], windows: RowAggregateWindows) -> RowAggregates { + var result = RowAggregates() + for row in rows { + if row.createdMs >= windows.sessionStartMs, row.createdMs < windows.nowMs { + result.sessionCost += row.cost + if result.oldestSessionMs.map({ row.createdMs < $0 }) ?? true { + result.oldestSessionMs = row.createdMs + } + } + if row.createdMs >= windows.weekStartMs, row.createdMs < windows.weekEndMs { + result.weeklyCost += row.cost + } + if row.createdMs >= windows.monthStartMs, row.createdMs < windows.monthEndMs { + result.monthlyCost += row.cost + } + } + return result + } + + /// Buckets local `opencode-go` message costs into calendar-day entries (device local time, + /// matching how Codex/Claude cost history is keyed) so the cost history chart can render a + /// per-day bar chart the same way it does for those providers. + private static func dailyEntries( + rows: [UsageRow], + now: Date, + historyDays: Int) -> [CostUsageDailyReport.Entry] + { + let clampedHistoryDays = max(1, min(365, historyDays)) + let calendar = Calendar.current + guard let since = calendar.date(byAdding: .day, value: -(clampedHistoryDays - 1), to: now) else { + return [] + } + let sinceStartOfDay = calendar.startOfDay(for: since) + + var totals: [String: (cost: Double, requestCount: Int)] = [:] + for row in rows { + let date = Date(timeIntervalSince1970: TimeInterval(row.createdMs) / 1000) + guard date >= sinceStartOfDay, date <= now else { continue } + let key = CostUsageScanner.CostUsageDayRange.dayKey(from: date) + var bucket = totals[key] ?? (cost: 0, requestCount: 0) + bucket.cost += row.cost + bucket.requestCount += row.requestCount + totals[key] = bucket + } + + return totals.keys.sorted().compactMap { key in + guard let bucket = totals[key] else { return nil } + return CostUsageDailyReport.Entry( + date: key, + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + requestCount: bucket.requestCount, + costUSD: bucket.cost, + modelsUsed: nil, + modelBreakdowns: nil) } } @@ -217,15 +303,6 @@ public struct OpenCodeGoLocalUsageReader: Sendable { return (value * 10).rounded() / 10 } - private static func rollingReset(rows: [UsageRow], nowMs: Int64) -> Int { - let sessionStart = nowMs - Int64(Self.fiveHours * 1000) - let oldest = rows - .filter { $0.createdMs >= sessionStart && $0.createdMs < nowMs } - .map(\.createdMs) - .min() ?? nowMs - return max(0, Int((oldest + Int64(Self.fiveHours * 1000) - nowMs) / 1000)) - } - private static func startOfUTCWeek(now: Date) -> Date { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? TimeZone.current @@ -316,7 +393,7 @@ public struct OpenCodeGoLocalUsageReader: Sendable { public init(homeDirectory _: URL = FileManager.default.homeDirectoryForCurrentUser) {} public init(authURL _: URL, databaseURL _: URL) {} - public func fetch(now _: Date = Date()) throws -> OpenCodeGoUsageSnapshot { + public func fetch(now _: Date = Date(), historyDays _: Int = 30) throws -> OpenCodeGoUsageSnapshot { throw OpenCodeGoLocalUsageError.notSupported } } diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift index 303d1ee2b3..2bf0340f39 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift @@ -33,8 +33,10 @@ public enum OpenCodeGoProviderDescriptor { ProviderColor(hex: 0xCFCECD), ]), tokenCost: ProviderTokenCostConfig( - supportsTokenCost: false, - noDataMessage: { "OpenCode Go cost summary is not supported." }), + supportsTokenCost: true, + noDataMessage: { + "No OpenCode Go local usage history found in ~/.local/share/opencode/opencode.db." + }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .web], pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), @@ -74,7 +76,7 @@ struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy { } private func snapshot(context: ProviderFetchContext) async throws -> OpenCodeGoUsageSnapshot { - let snapshot = try OpenCodeGoLocalUsageReader().fetch() + let snapshot = try OpenCodeGoLocalUsageReader().fetch(historyDays: context.costUsageHistoryDays) guard context.includeOptionalUsage, context.settings?.opencodego?.cookieSource != .off else { diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift index 0920cd8e06..dd865b87f6 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift @@ -10,8 +10,9 @@ public struct OpenCodeGoUsageSnapshot: Sendable { public let rollingResetInSec: Int public let weeklyResetInSec: Int public let monthlyResetInSec: Int - public let zenBalanceUSD: Double? + public private(set) var zenBalanceUSD: Double? public let renewsAt: Date? + public private(set) var daily: [CostUsageDailyReport.Entry] public let updatedAt: Date public init( @@ -26,6 +27,7 @@ public struct OpenCodeGoUsageSnapshot: Sendable { monthlyResetInSec: Int, zenBalanceUSD: Double? = nil, renewsAt: Date? = nil, + daily: [CostUsageDailyReport.Entry] = [], updatedAt: Date) { self.isBalanceOnly = isBalanceOnly @@ -39,6 +41,7 @@ public struct OpenCodeGoUsageSnapshot: Sendable { self.monthlyResetInSec = monthlyResetInSec self.zenBalanceUSD = zenBalanceUSD self.renewsAt = renewsAt + self.daily = daily self.updatedAt = updatedAt } @@ -63,6 +66,7 @@ public struct OpenCodeGoUsageSnapshot: Sendable { primary: nil, secondary: nil, providerCost: self.providerCostSnapshot, + opencodegoUsage: self, updatedAt: self.updatedAt, identity: nil) } @@ -112,6 +116,7 @@ public struct OpenCodeGoUsageSnapshot: Sendable { tertiary: tertiary, extraRateWindows: extraWindows, providerCost: self.providerCostSnapshot, + opencodegoUsage: self, updatedAt: self.updatedAt, identity: nil) } @@ -128,18 +133,23 @@ public struct OpenCodeGoUsageSnapshot: Sendable { } public func withZenBalanceUSD(_ balance: Double?) -> OpenCodeGoUsageSnapshot { - OpenCodeGoUsageSnapshot( - isBalanceOnly: self.isBalanceOnly, - hasWeeklyUsage: self.hasWeeklyUsage, - hasMonthlyUsage: self.hasMonthlyUsage, - rollingUsagePercent: self.rollingUsagePercent, - weeklyUsagePercent: self.weeklyUsagePercent, - monthlyUsagePercent: self.monthlyUsagePercent, - rollingResetInSec: self.rollingResetInSec, - weeklyResetInSec: self.weeklyResetInSec, - monthlyResetInSec: self.monthlyResetInSec, - zenBalanceUSD: balance, - renewsAt: self.renewsAt, - updatedAt: self.updatedAt) + var copy = self + copy.zenBalanceUSD = balance + return copy + } + + public func withDaily(_ daily: [CostUsageDailyReport.Entry]) -> OpenCodeGoUsageSnapshot { + var copy = self + copy.daily = daily + return copy + } + + /// Projects the local per-day cost buckets into the shared cost-history model so OpenCode Go + /// can reuse the same daily usage chart as Codex/Claude instead of a bespoke view. + public func toCostUsageTokenSnapshot(historyDays: Int = 30) -> CostUsageTokenSnapshot { + CostUsageFetcher.tokenSnapshot( + from: CostUsageDailyReport(data: self.daily, summary: nil), + now: self.updatedAt, + historyDays: historyDays) } } diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 91475fce3e..bbebe4a216 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -192,6 +192,7 @@ public struct UsageSnapshot: Codable, Sendable { public let deepseekUsage: DeepSeekUsageSummary? public let deepseekDetailedUsageState: DeepSeekDetailedUsageState public let deepseekPlatformProfiles: [DeepSeekPlatformProfile] + public let opencodegoUsage: OpenCodeGoUsageSnapshot? public let mimoUsage: MiMoUsageSnapshot? public let openRouterUsage: OpenRouterUsageSnapshot? public let sakanaPayAsYouGo: SakanaPayAsYouGoSnapshot? @@ -262,6 +263,7 @@ public struct UsageSnapshot: Codable, Sendable { deepseekUsage: DeepSeekUsageSummary? = nil, deepseekDetailedUsageState: DeepSeekDetailedUsageState = .notRequested, deepseekPlatformProfiles: [DeepSeekPlatformProfile] = [], + opencodegoUsage: OpenCodeGoUsageSnapshot? = nil, mimoUsage: MiMoUsageSnapshot? = nil, openRouterUsage: OpenRouterUsageSnapshot? = nil, sakanaPayAsYouGo: SakanaPayAsYouGoSnapshot? = nil, @@ -297,6 +299,7 @@ public struct UsageSnapshot: Codable, Sendable { self.deepseekUsage = deepseekUsage self.deepseekDetailedUsageState = deepseekDetailedUsageState self.deepseekPlatformProfiles = deepseekPlatformProfiles + self.opencodegoUsage = opencodegoUsage self.mimoUsage = mimoUsage self.openRouterUsage = openRouterUsage self.sakanaPayAsYouGo = sakanaPayAsYouGo @@ -349,6 +352,7 @@ public struct UsageSnapshot: Codable, Sendable { self.deepseekUsage = nil // Not persisted, fetched fresh each time self.deepseekDetailedUsageState = .notRequested // Live-only fetch state self.deepseekPlatformProfiles = [] // Live-only browser profile catalog + self.opencodegoUsage = nil // Not persisted, fetched fresh each time self.mimoUsage = try container.decodeIfPresent(MiMoUsageSnapshot.self, forKey: .mimoUsage) self.openRouterUsage = try container.decodeIfPresent(OpenRouterUsageSnapshot.self, forKey: .openRouterUsage) self.sakanaPayAsYouGo = try container.decodeIfPresent( @@ -616,6 +620,7 @@ public struct UsageSnapshot: Codable, Sendable { deepseekUsage: deepseekUsage.resolving(self.deepseekUsage), deepseekDetailedUsageState: deepseekDetailedUsageState.resolving(self.deepseekDetailedUsageState), deepseekPlatformProfiles: deepseekPlatformProfiles.resolving(self.deepseekPlatformProfiles), + opencodegoUsage: self.opencodegoUsage, mimoUsage: self.mimoUsage, openRouterUsage: self.openRouterUsage, sakanaPayAsYouGo: self.sakanaPayAsYouGo, diff --git a/Tests/CodexBarTests/GroqMenuCardModelTests.swift b/Tests/CodexBarTests/GroqMenuCardModelTests.swift new file mode 100644 index 0000000000..8a903934ed --- /dev/null +++ b/Tests/CodexBarTests/GroqMenuCardModelTests.swift @@ -0,0 +1,65 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension StatusMenuTests { + @Test + func `groq cost data stays reachable via inline dashboard regardless of cost row gating`() throws { + StatusItemController.menuCardRenderingEnabled = true + StatusItemController.setMenuRefreshEnabledForTesting(false) + defer { self.disableMenuCardsForTesting() } + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.selectedMenuProvider = .groq + settings.costUsageEnabled = true + settings.costSummaryDisplayStyle = .costSubmenu + + let metadata = try #require(ProviderRegistry.shared.metadata[.groq]) + settings.setProviderEnabled(provider: .groq, metadata: metadata, enabled: true) + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let now = Date(timeIntervalSince1970: 1_700_179_200) + let usage = GroqConsoleUsageSnapshot( + daily: [ + GroqConsoleUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: now.addingTimeInterval(-86400), + endTime: now, + costUSD: 1.5, + requests: 10, + inputTokens: 100, + cachedInputTokens: 0, + outputTokens: 50, + totalTokens: 150, + models: []), + ], + updatedAt: now) + store._setSnapshotForTesting(usage.toUsageSnapshot(), provider: .groq) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + // Groq's descriptor sets `tokenCost.supportsTokenCost = false`, so the generic Cost + // row/submenu is unreachable regardless of display style or `tokenCostMenuSectionEnabled` + // (that guard runs first in `tokenUsageSection`) — Groq relies solely on the inline + // dashboard for its cost data, same as openai/mistral. This locks in that Groq's absence + // from the "Cost" row is unaffected by which provider set gates that row, so a future + // predicate change there can't silently break Groq the way it silently broke when this + // row's gate briefly reused `usesProviderCostHistoryAsPrimaryDashboard`. + let model = try #require(controller.menuCardModel(for: .groq)) + #expect(model.tokenUsage == nil) + #expect(model.inlineUsageDashboard != nil) + } +} diff --git a/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift b/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift index ca060b0a33..61eff060e1 100644 --- a/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift +++ b/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift @@ -37,6 +37,46 @@ struct OpenCodeGoLocalUsageReaderTests { #expect(snapshot.monthlyResetInSec == 1_626_796) } + @Test + func `builds daily cost history buckets within the requested window`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.writeAuth(to: env.authURL) + try Self.createDatabase(at: env.databaseURL) + // Expected keys below use the same device-local calendar convention as production. + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-06T12:00:00.000Z"), + cost: 3.0) + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-06T13:00:00.000Z"), + cost: 1.5) + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-05T12:00:00.000Z"), + cost: 6.0) + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-01-01T12:00:00.000Z"), + cost: 100.0) + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + let now = Date(timeIntervalSince1970: TimeInterval(Self.ms("2026-03-06T15:00:00.000Z")) / 1000) + let snapshot = try reader.fetch(now: now, historyDays: 30) + + let previousDayKey = CostUsageScanner.CostUsageDayRange.dayKey( + from: Date(timeIntervalSince1970: TimeInterval(Self.ms("2026-03-05T12:00:00.000Z")) / 1000)) + let currentDayKey = CostUsageScanner.CostUsageDayRange.dayKey( + from: Date(timeIntervalSince1970: TimeInterval(Self.ms("2026-03-06T12:00:00.000Z")) / 1000)) + #expect(snapshot.daily.map(\.date) == [previousDayKey, currentDayKey]) + #expect(snapshot.daily.first?.costUSD == 6.0) + #expect(snapshot.daily.first?.requestCount == 1) + #expect(snapshot.daily.last?.costUSD == 4.5) + #expect(snapshot.daily.last?.requestCount == 2) + } + @Test func `auth without history falls through to web strategy`() throws { let env = try Self.makeEnvironment() @@ -118,7 +158,7 @@ struct OpenCodeGoLocalUsageReaderTests { } @Test - func `does not double count step finish parts when message has cost`() throws { + func `uses message cost while counting step finish requests`() throws { let env = try Self.makeEnvironment() defer { try? FileManager.default.removeItem(at: env.root) } @@ -132,7 +172,12 @@ struct OpenCodeGoLocalUsageReaderTests { databaseURL: env.databaseURL, messageID: messageID, createdMs: Self.ms("2026-03-06T11:00:00.000Z"), - cost: 3.0) + cost: 1.0) + try Self.insertStepFinishPart( + databaseURL: env.databaseURL, + messageID: messageID, + createdMs: Self.ms("2026-03-06T11:05:00.000Z"), + cost: 2.0) let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) let snapshot = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400)) @@ -140,6 +185,46 @@ struct OpenCodeGoLocalUsageReaderTests { #expect(snapshot.rollingUsagePercent == 25) #expect(snapshot.weeklyUsagePercent == 10) #expect(snapshot.monthlyUsagePercent == 5) + #expect(snapshot.daily.first?.costUSD == 3.0) + #expect(snapshot.daily.first?.requestCount == 2) + } + + @Test + func `daily request count buckets step finish parts by their timestamps`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.writeAuth(to: env.authURL) + try Self.createDatabase(at: env.databaseURL) + let anchor = Date(timeIntervalSince1970: TimeInterval(Self.ms("2026-03-06T15:00:00.000Z")) / 1000) + let dayStart = Calendar.current.startOfDay(for: anchor) + let now = dayStart.addingTimeInterval(6 * 60 * 60) + let beforeMidnight = dayStart.addingTimeInterval(-60) + let afterMidnight = dayStart.addingTimeInterval(60) + // One assistant turn can make provider requests on opposite sides of local midnight. + let messageID = try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms(beforeMidnight), + cost: nil) + try Self.insertStepFinishPart( + databaseURL: env.databaseURL, + messageID: messageID, + createdMs: Self.ms(beforeMidnight), + cost: 1.0) + try Self.insertStepFinishPart( + databaseURL: env.databaseURL, + messageID: messageID, + createdMs: Self.ms(afterMidnight), + cost: 2.0) + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + let snapshot = try reader.fetch(now: now, historyDays: 30) + + #expect(snapshot.daily.count == 2) + #expect(snapshot.daily.first?.costUSD == 1.0) + #expect(snapshot.daily.first?.requestCount == 1) + #expect(snapshot.daily.last?.costUSD == 2.0) + #expect(snapshot.daily.last?.requestCount == 1) } @Test @@ -288,6 +373,10 @@ struct OpenCodeGoLocalUsageReaderTests { return Int64((formatter.date(from: iso)?.timeIntervalSince1970 ?? 0) * 1000) } + private static func ms(_ date: Date) -> Int64 { + Int64(date.timeIntervalSince1970 * 1000) + } + private enum SQLiteTestError: Error { case open case prepare diff --git a/Tests/CodexBarTests/OpenCodeGoMenuCardModelTests.swift b/Tests/CodexBarTests/OpenCodeGoMenuCardModelTests.swift index c92558a46d..c211d03899 100644 --- a/Tests/CodexBarTests/OpenCodeGoMenuCardModelTests.swift +++ b/Tests/CodexBarTests/OpenCodeGoMenuCardModelTests.swift @@ -181,4 +181,117 @@ struct OpenCodeGoMenuCardModelTests { #expect(model.providerCost == nil) } + + @Test + func `inline dashboard falls back to inline chart when cost row is unavailable`() throws { + // "Inline only" cost display style: tokenCostMenuSectionEnabled is false (no Cost row), + // but tokenCostInlineDashboardEnabled is true. OpenCode Go should behave like + // Codex/Claude/Cursor here and still surface its cost history via the inline chart. + let now = Date() + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + tertiary: nil, + updatedAt: now, + identity: nil) + let metadata = try #require(ProviderDefaults.metadata[.opencodego]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: 0.78, + last30DaysTokens: nil, + last30DaysCostUSD: 22.13, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-07-17", + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + requestCount: 200, + costUSD: 0.78, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .opencodego, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + tokenCostInlineDashboardEnabled: true, + tokenCostMenuSectionEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.tokenUsage == nil) + #expect(model.inlineUsageDashboard != nil) + } + + @Test + func `cost row takes precedence over inline chart when both are enabled`() throws { + // "Both" cost display style: matches Codex/Claude, which show the Cost row and the + // inline chart simultaneously rather than one suppressing the other. + let now = Date() + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + tertiary: nil, + updatedAt: now, + identity: nil) + let metadata = try #require(ProviderDefaults.metadata[.opencodego]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: 0.78, + last30DaysTokens: nil, + last30DaysCostUSD: 22.13, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-07-17", + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + requestCount: 200, + costUSD: 0.78, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .opencodego, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + tokenCostInlineDashboardEnabled: true, + tokenCostMenuSectionEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.tokenUsage != nil) + #expect(model.inlineUsageDashboard != nil) + } } diff --git a/Tests/CodexBarTests/OpenCodeGoTokenCostTests.swift b/Tests/CodexBarTests/OpenCodeGoTokenCostTests.swift new file mode 100644 index 0000000000..8b7eba0b5d --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeGoTokenCostTests.swift @@ -0,0 +1,79 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct OpenCodeGoTokenCostTests { + @Test + func `token snapshot projection is nil when local daily history is empty`() { + let settings = Self.makeSettings() + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + // Web-only source mode and machines without a readable local database leave + // `opencodegoUsage` present but `daily` empty. A dataless projection here would + // otherwise still surface a Cost row whose history submenu has nothing to render. + let emptySnapshot = OpenCodeGoUsageSnapshot( + hasMonthlyUsage: true, + rollingUsagePercent: 12, + weeklyUsagePercent: 57, + monthlyUsagePercent: 34, + rollingResetInSec: 3600, + weeklyResetInSec: 86400, + monthlyResetInSec: 864_000, + daily: [], + updatedAt: Date()) + + #expect(store.tokenSnapshot( + fromProviderSnapshot: emptySnapshot.toUsageSnapshot(), + provider: .opencodego) == nil) + } + + @Test + func `token snapshot projection is populated when local daily history exists`() { + let settings = Self.makeSettings() + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + let populatedSnapshot = OpenCodeGoUsageSnapshot( + hasMonthlyUsage: true, + rollingUsagePercent: 12, + weeklyUsagePercent: 57, + monthlyUsagePercent: 34, + rollingResetInSec: 3600, + weeklyResetInSec: 86400, + monthlyResetInSec: 864_000, + daily: [ + CostUsageDailyReport.Entry( + date: "2025-12-23", + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + requestCount: 5, + costUSD: 1.23, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: Date()) + + let tokenSnapshot = store.tokenSnapshot( + fromProviderSnapshot: populatedSnapshot.toUsageSnapshot(), + provider: .opencodego) + #expect(tokenSnapshot?.daily.isEmpty == false) + #expect(tokenSnapshot?.last30DaysCostUSD == 1.23) + } + + private static func makeSettings() -> SettingsStore { + let suite = "OpenCodeGoTokenCostTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.providerDetectionCompleted = true + return settings + } +} diff --git a/Tests/CodexBarTests/SpendDashboardModelTests.swift b/Tests/CodexBarTests/SpendDashboardModelTests.swift index 105ef14a56..a7c66703fe 100644 --- a/Tests/CodexBarTests/SpendDashboardModelTests.swift +++ b/Tests/CodexBarTests/SpendDashboardModelTests.swift @@ -68,7 +68,7 @@ struct SpendDashboardModelTests { let providers = Set(ProviderDescriptorRegistry.all .filter(\.tokenCost.supportsTokenCost) .map(\.id)) - #expect(providers == [.codex, .claude, .vertexai, .openai, .mistral, .bedrock, .cursor]) + #expect(providers == [.codex, .claude, .vertexai, .openai, .mistral, .bedrock, .cursor, .opencodego]) } @Test diff --git a/Tests/CodexBarTests/StatusMenuNativeSectionSpacingTests.swift b/Tests/CodexBarTests/StatusMenuNativeSectionSpacingTests.swift index 91b96f2b77..c604c07dd6 100644 --- a/Tests/CodexBarTests/StatusMenuNativeSectionSpacingTests.swift +++ b/Tests/CodexBarTests/StatusMenuNativeSectionSpacingTests.swift @@ -156,6 +156,82 @@ struct StatusMenuNativeSectionSpacingTests { }) } + @Test + func `opencodego cost history hangs off the cost row not the usage pane`() throws { + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .opencodego + settings.costUsageEnabled = true + settings.costSummaryDisplayStyle = .both + self.enableOnlyOpenCodeGo(settings) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let opencodegoSnapshot = OpenCodeGoUsageSnapshot( + hasMonthlyUsage: true, + rollingUsagePercent: 12, + weeklyUsagePercent: 57, + monthlyUsagePercent: 34, + rollingResetInSec: 3600, + weeklyResetInSec: 86400, + monthlyResetInSec: 864_000, + daily: [ + CostUsageDailyReport.Entry( + date: "2025-12-23", + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + requestCount: 5, + costUSD: 1.23, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: Date()) + let opencodegoUsageSnapshot = opencodegoSnapshot.toUsageSnapshot() + store._setSnapshotForTesting(opencodegoUsageSnapshot, provider: .opencodego) + // A completed refresh also caches the projected token snapshot (UsageStore+Refresh.swift); + // populate it here so `openAIWebContext.hasCostHistory` matches real post-refresh state. + store._setTokenSnapshotForTesting( + store.tokenSnapshot(fromProviderSnapshot: opencodegoUsageSnapshot, provider: .opencodego), + provider: .opencodego) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .opencodego) + controller.menuWillOpen(menu) + + let usageIndex = try #require(menu.items.firstIndex { + ($0.representedObject as? String) == "menuCardUsage" + }) + let usageHistoryIndex = try #require(menu.items.firstIndex { + ($0.representedObject as? String) == "usageHistorySubmenu" + }) + let costIndex = try #require(menu.items.firstIndex { + ($0.representedObject as? String) == "menuCardCost" + }) + + // The rate-limit bars pane keeps its own submenu-free row; the cost history chart hangs + // off the dedicated "Cost" row instead, matching Codex/Claude's structure. + #expect(menu.items[usageIndex].submenu == nil) + #expect(menu.items[usageHistoryIndex].title == "Plan Usage") + #expect(usageIndex < usageHistoryIndex) + #expect(usageHistoryIndex < costIndex) + #expect(menu.items[costIndex].submenu != nil) + } + private func makeSettings() -> SettingsStore { let suite = "StatusMenuNativeSectionSpacingTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! @@ -175,4 +251,11 @@ struct StatusMenuNativeSectionSpacingTests { settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) } } + + private func enableOnlyOpenCodeGo(_ settings: SettingsStore) { + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .opencodego) + } + } } diff --git a/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift b/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift index 00e53025d6..b1834e0d45 100644 --- a/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift @@ -64,6 +64,75 @@ extension StatusMenuTests { } == true) } + @Test + func `overview row shows plan usage not cost history for opencodego`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .opencodego + settings.mergedMenuLastSelectedWasOverview = true + settings.costUsageEnabled = true + // Deliberately NOT `.costSubmenu`/`.both`: opencodego has real rate-limit bars (unlike + // mistral), so its Overview row must fall through to Plan Usage here rather than + // unconditionally preferring cost history the way mistral's Overview row does. + settings.costSummaryDisplayStyle = .inlineSummary + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .opencodego || provider == .codex + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let opencodegoSnapshot = OpenCodeGoUsageSnapshot( + hasMonthlyUsage: true, + rollingUsagePercent: 12, + weeklyUsagePercent: 57, + monthlyUsagePercent: 34, + rollingResetInSec: 3600, + weeklyResetInSec: 86400, + monthlyResetInSec: 864_000, + daily: [ + CostUsageDailyReport.Entry( + date: "2025-12-23", + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + requestCount: 5, + costUSD: 1.23, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: Date()) + store._setSnapshotForTesting(opencodegoSnapshot.toUsageSnapshot(), provider: .opencodego) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + let opencodegoRow = try #require(menu.items.first { + ($0.representedObject as? String) == "overviewRow-opencodego" + }) + #expect(opencodegoRow.submenu?.items.contains { + ($0.representedObject as? String) == StatusItemController.usageHistoryChartID + } == true) + #expect(opencodegoRow.submenu?.items.contains { + ($0.representedObject as? String) == StatusItemController.costHistoryChartID + } == false) + } + @Test func `overview row submenu action does not switch provider detail`() throws { self.disableMenuCardsForTesting() diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index 8f28278141..0a6db50ab9 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -198,6 +198,36 @@ struct UsageStorePlanUtilizationTests { #expect(model.selectedSeries == "session:300") } + @MainActor + @Test + func `opencodego history tabs include the monthly series`() { + let histories = [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 12), + ]), + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_086_400), usedPercent: 57), + ]), + planSeries(name: .monthly, windowMinutes: 43200, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_086_400), usedPercent: 34), + ]), + ] + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 57, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 34, windowMinutes: 43200, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_086_400), + identity: nil) + + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + histories: histories, + provider: .opencodego, + snapshot: snapshot) + + #expect(model.visibleSeries == ["session:300", "weekly:10080", "monthly:43200"]) + #expect(model.selectedSeries == "session:300") + } + @MainActor @Test func `session chart uses native reset boundaries and fills missing windows`() throws { @@ -772,6 +802,42 @@ struct UsageStorePlanUtilizationTests { #expect(findSeries(histories, name: .opus, windowMinutes: 10080)?.entries.last?.usedPercent == 30) } + @MainActor + @Test + func `opencodego plan history is always supported like codex and claude`() { + let store = Self.makeStore() + #expect(store.settings.historicalTrackingEnabled == false) + #expect(store.supportsPlanUtilizationHistory(for: .opencodego)) + } + + @MainActor + @Test + func `record plan history stores opencodego monthly series`() async { + let store = Self.makeStore() + // historicalTrackingEnabled defaults to false; opencodego must still record, like codex/claude. + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 57, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 34, windowMinutes: 43200, resetsAt: nil, resetDescription: nil), + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .opencodego, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil)) + store._setSnapshotForTesting(snapshot, provider: .opencodego) + + await store.recordPlanUtilizationHistorySample( + provider: .opencodego, + snapshot: snapshot, + now: Date(timeIntervalSince1970: 1_700_000_000)) + + let histories = store.planUtilizationHistory(for: .opencodego) + #expect(findSeries(histories, name: .session, windowMinutes: 300)?.entries.last?.usedPercent == 12) + #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 57) + #expect(findSeries(histories, name: .monthly, windowMinutes: 43200)?.entries.last?.usedPercent == 34) + } + @MainActor @Test func `generic provider weekly lane is persisted to provider history json`() async throws { diff --git a/docs/opencode.md b/docs/opencode.md index d65c9d5159..7d4c0b8903 100644 --- a/docs/opencode.md +++ b/docs/opencode.md @@ -29,4 +29,9 @@ read_when: - Workspace override accepts a raw `wrk_…` ID or a full `https://opencode.ai/workspace/...` URL. - Cached cookies: Keychain cache `com.steipete.codexbar.cache` (account `cookie.opencode`, source + timestamp). Browser import only runs when the cached cookie fails. -- OpenCode Go auto mode tries web usage first, then derives quota windows from local `opencode-go` assistant costs. +- OpenCode Go auto mode tries web usage first. Authentication/setup failures fall back to quota windows and daily cost + history derived from local `opencode-go` assistant costs. +- OpenCode Go cost history chart: `opencode.ai` has no daily-granularity endpoint, so per-day cost/request buckets + come from local `opencode-go` assistant costs in `opencode.db`, keyed by device-local calendar day. Successful web + usage remains workspace-scoped and is never blended with device-wide local costs, so it does not show cost history. + Explicit Web mode never reads the local database either.