From 10a764385309ad8be0e6fed989a7907b21e089fc Mon Sep 17 00:00:00 2001 From: "kentoku.matsunami" Date: Sat, 18 Jul 2026 21:07:39 +0900 Subject: [PATCH 1/9] Add daily cost and plan usage history for OpenCode Go Mirrors the Codex/Claude menu structure: the top rate-limit pane stays submenu-free, a "Plan Usage" row hover-expands into 5-hour/weekly/monthly history tabs, and a "Cost" row hover-expands into a daily $ chart sourced from the local opencode-go SQLite history (~/.local/share/opencode/opencode.db). The web-sourced fetch strategy best-effort enriches its rolling/weekly percentages with the local daily buckets (opencode.ai has no daily- granularity endpoint), but skips that local read entirely when the user explicitly selects Web-only source mode. Co-Authored-By: Claude Sonnet 5 --- .../PlanUtilizationHistoryChartMenuView.swift | 2 +- .../CodexBar/StatusItemController+Menu.swift | 6 +- .../StatusItemController+MenuCardModel.swift | 6 +- ...tatusItemController+OverviewSubmenus.swift | 6 +- .../CodexBar/UsageStore+PlanUtilization.swift | 6 +- Sources/CodexBar/UsageStore+TokenCost.swift | 9 +- .../OpenCodeGoLocalUsageReader.swift | 145 ++++++++++++++---- .../OpenCodeGoProviderDescriptor.swift | 29 +++- .../OpenCodeGo/OpenCodeGoUsageSnapshot.swift | 38 +++-- Sources/CodexBarCore/UsageFetcher.swift | 5 + .../OpenCodeGoLocalUsageReaderTests.swift | 81 ++++++++++ .../StatusMenuNativeSectionSpacingTests.swift | 83 ++++++++++ .../StatusMenuOverviewSubmenuTests.swift | 69 +++++++++ .../UsageStorePlanUtilizationTests.swift | 66 ++++++++ docs/opencode.md | 5 + 15 files changed, 501 insertions(+), 55 deletions(-) diff --git a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift index b704754af5..2ae2a7dcc6 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift @@ -250,7 +250,7 @@ struct PlanUtilizationHistoryChartMenuView: View { case .codex: if snapshot.primary != nil { names.insert(.session) } if snapshot.secondary != nil { names.insert(.weekly) } - case .claude: + case .claude, .opencodego: if snapshot.primary != nil { names.insert(.session) } if snapshot.secondary != nil { names.insert(.weekly) } if snapshot.tertiary != nil, diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index e83981879a..8bcc2c7f7f 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -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 { diff --git a/Sources/CodexBar/StatusItemController+MenuCardModel.swift b/Sources/CodexBar/StatusItemController+MenuCardModel.swift index 7cfe7cb15d..28e5293036 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardModel.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardModel.swift @@ -161,7 +161,11 @@ extension StatusItemController { tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: target), codexLocalSessionCostLedgerEnabled: self.settings.codexLocalSessionCostLedgerEnabled, tokenCostInlineDashboardEnabled: self.settings.costSummaryShowsInlineDashboard(for: target), - tokenCostMenuSectionEnabled: !UsageStore.tokenCostRequiresProviderSnapshot(target) && + // Providers whose cost history already surfaces via the inline dashboard or a + // dedicated top-pane submenu (openai/mistral) skip the generic "Cost" row; other + // `tokenCostRequiresProviderSnapshot` providers (e.g. opencodego) show real rate-limit + // bars in the top pane instead and need the dedicated row to surface cost at all. + tokenCostMenuSectionEnabled: !UsageMenuCardView.Model.usesProviderCostHistoryAsPrimaryDashboard(target) && 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..06a9d50c0f 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 @@ -574,7 +574,7 @@ extension UsageStore { for lane in projection.planUtilizationLanes { appendWindow(lane.window, name: lane.role) } - case .claude: + case .claude, .opencodego: appendWindow(snapshot.primary, name: .session) appendWindow(snapshot.secondary, name: .weekly) appendWindow(snapshot.tertiary, name: .opus) diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index d31246d785..ee6770f9e3 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -403,6 +403,13 @@ 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 +417,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..0f54881a90 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,16 @@ 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) + } + + /// Best-effort daily cost history, independent of `fetch()`'s window-percentage flow. The + /// opencode.ai web API has no daily-granularity endpoint, so the web fetch strategy calls this + /// to enrich its rolling/weekly percentages (server-authoritative) with local per-day cost + /// (device-only). Returns an empty array rather than throwing when local history is unavailable. + public func fetchDaily(now: Date = Date(), historyDays: Int = 30) -> [CostUsageDailyReport.Entry] { + guard let rows = try? self.readRows() else { return [] } + return Self.dailyEntries(rows: rows, now: now, historyDays: historyDays) } private func readRows() throws -> [UsageRow] { @@ -96,7 +105,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 messageID = sqlite3_column_text(stmt, 2).map { String(cString: $0) } ?? "" + rows.append(UsageRow(createdMs: createdMs, cost: cost, messageID: messageID)) } return rows } @@ -122,7 +132,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, + id AS messageID FROM message WHERE json_valid(data) AND json_extract(data, '$.providerID') = 'opencode-go' @@ -142,13 +153,14 @@ public struct OpenCodeGoLocalUsageReader: Sendable { AND json_extract(data, '$.role') = 'assistant' AND json_type(data, '$.cost') IN ('integer', 'real') ) - SELECT createdMs, cost + SELECT createdMs, cost, messageID FROM message_costs UNION ALL SELECT CAST(COALESCE(json_extract(p.data, '$.time.created'), p.time_created, m.time_created) AS INTEGER) AS createdMs, - CAST(json_extract(p.data, '$.cost') AS REAL) AS cost + CAST(json_extract(p.data, '$.cost') AS REAL) AS cost, + p.message_id AS messageID FROM part p JOIN message m ON m.id = p.message_id WHERE json_valid(p.data) @@ -167,6 +179,10 @@ public struct OpenCodeGoLocalUsageReader: Sendable { private struct UsageRow { let createdMs: Int64 let cost: Double + /// Distinguishes distinct assistant turns so daily requestCount doesn't overcount a single + /// message whose cost is spread across multiple step-finish parts (messageAndPartUsageSQL's + /// per-part UNION ALL branch emits one row per part for such messages). + let messageID: String } private static func hasAuthKey(at url: URL) -> Bool { @@ -180,7 +196,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 +205,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, messageIDs: Set)] = [:] + 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, messageIDs: []) + bucket.cost += row.cost + bucket.messageIDs.insert(row.messageID) + 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.messageIDs.count, + costUSD: bucket.cost, + modelsUsed: nil, + modelBreakdowns: nil) } } @@ -217,15 +311,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,9 +401,13 @@ 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 } + + public func fetchDaily(now _: Date = Date(), historyDays _: Int = 30) -> [CostUsageDailyReport.Entry] { + [] + } } #endif diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift index 303d1ee2b3..921fdc6ad0 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 { @@ -138,7 +140,7 @@ struct OpenCodeGoUsageFetchStrategy: ProviderFetchStrategy { workspaceIDOverride: workspaceOverride, includeZenBalance: context.includeOptionalUsage) return self.makeResult( - usage: snapshot.toUsageSnapshot(), + usage: Self.enrichedUsageSnapshot(snapshot, context: context), sourceLabel: "web") } catch OpenCodeGoUsageError.invalidCredentials where cookieSource != .manual { #if os(macOS) @@ -150,7 +152,7 @@ struct OpenCodeGoUsageFetchStrategy: ProviderFetchStrategy { workspaceIDOverride: workspaceOverride, includeZenBalance: context.includeOptionalUsage) return self.makeResult( - usage: snapshot.toUsageSnapshot(), + usage: Self.enrichedUsageSnapshot(snapshot, context: context), sourceLabel: "web") #else throw OpenCodeGoUsageError.invalidCredentials @@ -158,6 +160,23 @@ struct OpenCodeGoUsageFetchStrategy: ProviderFetchStrategy { } } + /// opencode.ai's web usage endpoint has no daily-granularity data, so the cost history chart + /// would stay empty for auto-mode users whose web session succeeds before the local fallback + /// ever runs. Best-effort blend in the on-device per-day cost history here instead — but only + /// when the local strategy could also have run in this source mode. Explicit `.web` mode + /// deliberately excludes `OpenCodeGoLocalUsageFetchStrategy` from the pipeline (see + /// `resolveStrategies`), so honor that choice here too rather than silently reading the local + /// database anyway. + private static func enrichedUsageSnapshot( + _ snapshot: OpenCodeGoUsageSnapshot, + context: ProviderFetchContext) -> UsageSnapshot + { + guard context.sourceMode != .web else { return snapshot.toUsageSnapshot() } + let daily = OpenCodeGoLocalUsageReader().fetchDaily(historyDays: context.costUsageHistoryDays) + guard !daily.isEmpty else { return snapshot.toUsageSnapshot() } + return snapshot.withDaily(daily).toUsageSnapshot() + } + func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool { guard context.sourceMode == .auto else { return false } return switch error { 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/OpenCodeGoLocalUsageReaderTests.swift b/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift index ca060b0a33..c620800cac 100644 --- a/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift +++ b/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift @@ -37,6 +37,54 @@ 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) + // Noon UTC keeps these on the same calendar day across every real-world timezone offset. + 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) + + #expect(snapshot.daily.map(\.date) == ["2026-03-05", "2026-03-06"]) + #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) + + let daily = reader.fetchDaily(now: now, historyDays: 30) + #expect(daily.map(\.date) == snapshot.daily.map(\.date)) + } + + @Test + func `fetchDaily returns no entries when local history is unavailable`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + #expect(reader.fetchDaily().isEmpty) + } + @Test func `auth without history falls through to web strategy`() throws { let env = try Self.makeEnvironment() @@ -142,6 +190,39 @@ struct OpenCodeGoLocalUsageReaderTests { #expect(snapshot.monthlyUsagePercent == 5) } + @Test + func `daily request count counts messages not step finish part rows`() 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) + // One assistant turn with no message-level cost, costed via two separate step-finish + // parts (e.g. a multi-tool-call turn). This must still count as a single request. + let messageID = try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-06T11:00:00.000Z"), + cost: nil) + try Self.insertStepFinishPart( + databaseURL: env.databaseURL, + messageID: messageID, + createdMs: Self.ms("2026-03-06T11:00:00.000Z"), + 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 now = Date(timeIntervalSince1970: TimeInterval(Self.ms("2026-03-06T15:00:00.000Z")) / 1000) + let snapshot = try reader.fetch(now: now, historyDays: 30) + + #expect(snapshot.daily.count == 1) + #expect(snapshot.daily.first?.costUSD == 3.0) + #expect(snapshot.daily.first?.requestCount == 1) + } + @Test func `missing auth and history is not detected`() throws { let env = try Self.makeEnvironment() 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..63f4b51700 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 window as opus 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: .opus, 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", "opus: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 window as opus 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: .opus, 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..b4c26aee4d 100644 --- a/docs/opencode.md +++ b/docs/opencode.md @@ -30,3 +30,8 @@ read_when: - 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 cost history chart: `opencode.ai` has no daily-granularity endpoint, so per-day cost/request buckets + always come from the local `opencode-go` assistant costs in `opencode.db`, keyed by device-local calendar day. The + web usage strategy best-effort merges this local daily history alongside its server-sourced rolling/weekly percents + (`OpenCodeGoProviderDescriptor.enrichingWithLocalDaily`), so the chart still renders even when the web session + succeeds before the local fallback strategy would otherwise run. From c2e0bbdcab50ca8815fdbbe9f5aee826a3dc0f8c Mon Sep 17 00:00:00 2001 From: "kentoku.matsunami" Date: Sat, 18 Jul 2026 21:49:20 +0900 Subject: [PATCH 2/9] Suppress dataless OpenCode Go cost projection Web-only source mode and machines without a readable local database leave opencodegoUsage present but daily empty, which previously still produced a non-nil CostUsageTokenSnapshot and surfaced a Cost row with no history to show. Return nil until daily entries exist instead. Addresses PR review feedback on #2296. Co-Authored-By: Claude Sonnet 5 --- Sources/CodexBar/UsageStore+TokenCost.swift | 3 +- .../OpenCodeGoTokenCostTests.swift | 79 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 Tests/CodexBarTests/OpenCodeGoTokenCostTests.swift diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index ee6770f9e3..a55a14e72c 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -408,7 +408,8 @@ extension UsageStore { // `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) + usage.daily.isEmpty ? nil : usage + .toCostUsageTokenSnapshot(historyDays: self.settings.costUsageHistoryDays) } default: 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 + } +} From 54517230ac605c6b1f781dde6c7af71bcf21521d Mon Sep 17 00:00:00 2001 From: "kentoku.matsunami" Date: Sat, 18 Jul 2026 22:47:14 +0900 Subject: [PATCH 3/9] Fall back to inline cost chart for OpenCode Go in Inline-only style opencodego was missing from the shared CostUsageTokenSnapshot inline dashboard path (matching Codex/Claude/Vertex/Bedrock/Cursor), so users with Cost summary set to "Inline only" saw no OpenCode Go cost data at all: the Cost row is suppressed in that style, and there was no inline fallback either. Adds opencodego to that path so it now matches those providers exactly, including showing both the Cost row and the inline chart together in "Both" style. Addresses PR review feedback on #2296. Co-Authored-By: Claude Sonnet 5 --- .../InlineUsageDashboardContent.swift | 2 +- .../OpenCodeGoMenuCardModelTests.swift | 113 ++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index 9e58ab07cf..83464b3357 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/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) + } } From 2583854b03d974d614f63b5c95f2a5298321ec87 Mon Sep 17 00:00:00 2001 From: "kentoku.matsunami" Date: Sat, 18 Jul 2026 23:09:33 +0900 Subject: [PATCH 4/9] Decouple the Cost row gate from inline-dashboard membership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tokenCostMenuSectionEnabled reused usesProviderCostHistoryAsPrimaryDashboard to exclude openai/mistral from the generic "Cost" row. That predicate is shared with an unrelated concern (inline-dashboard eligibility) and later gained .groq for its own reasons, which would have silently excluded groq from this row too. Replace it with an explicit openai/mistral check so this gate no longer tracks membership changes made for other purposes. In practice groq's tokenCost.supportsTokenCost is false, so its Cost row was already unreachable independent of this gate — verified with a test locking in that groq's cost data stays visible via the inline dashboard regardless of how this predicate is defined. Addresses PR review feedback on #2296. Co-Authored-By: Claude Sonnet 5 --- .../StatusItemController+MenuCardModel.swift | 14 ++-- .../GroqMenuCardModelTests.swift | 65 +++++++++++++++++++ 2 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 Tests/CodexBarTests/GroqMenuCardModelTests.swift diff --git a/Sources/CodexBar/StatusItemController+MenuCardModel.swift b/Sources/CodexBar/StatusItemController+MenuCardModel.swift index 28e5293036..d58683c2f5 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardModel.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardModel.swift @@ -161,11 +161,15 @@ extension StatusItemController { tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: target), codexLocalSessionCostLedgerEnabled: self.settings.codexLocalSessionCostLedgerEnabled, tokenCostInlineDashboardEnabled: self.settings.costSummaryShowsInlineDashboard(for: target), - // Providers whose cost history already surfaces via the inline dashboard or a - // dedicated top-pane submenu (openai/mistral) skip the generic "Cost" row; other - // `tokenCostRequiresProviderSnapshot` providers (e.g. opencodego) show real rate-limit - // bars in the top pane instead and need the dedicated row to surface cost at all. - tokenCostMenuSectionEnabled: !UsageMenuCardView.Model.usesProviderCostHistoryAsPrimaryDashboard(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/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) + } +} From 59897de5c2a9e583bb4dfe178ef20d8ef17cf16d Mon Sep 17 00:00:00 2001 From: "kentoku.matsunami" Date: Sat, 18 Jul 2026 23:57:52 +0900 Subject: [PATCH 5/9] Rename hasProviderNativeCostHistorySubmenu for clarity The name implied a provider-native top-pane submenu (matching hasOpenAIAPIUsageSubmenu's naming), but opencodego satisfies this via its collapsible "Cost" row, not a top-pane submenu. Unlike the two predicate-reuse bugs fixed earlier in this PR, this reuse of tokenCostRequiresProviderSnapshot is intentional: any provider whose cost is sourced by projecting a UsageSnapshot field can only render that cost through addMenuCardSections's sectioned layout, so the two concepts are genuinely coupled here. Renamed to say what it actually gates and documented why the reuse is safe, so a future reader doesn't mistake it for the same bug class. Co-Authored-By: Claude Sonnet 5 --- Sources/CodexBar/StatusItemController+Menu.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 8bcc2c7f7f..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, @@ -1577,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 } From e62270fc812c7f3b8eb2f3bf2f06705099e6910d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Jul 2026 19:47:47 +0100 Subject: [PATCH 6/9] docs: clarify OpenCode Go history sources --- docs/opencode.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/opencode.md b/docs/opencode.md index b4c26aee4d..f7000a0e4e 100644 --- a/docs/opencode.md +++ b/docs/opencode.md @@ -29,9 +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. A successful web fetch best-effort adds local daily cost history; + authentication/setup failures fall back to quota windows 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 - always come from the local `opencode-go` assistant costs in `opencode.db`, keyed by device-local calendar day. The - web usage strategy best-effort merges this local daily history alongside its server-sourced rolling/weekly percents - (`OpenCodeGoProviderDescriptor.enrichingWithLocalDaily`), so the chart still renders even when the web session - succeeds before the local fallback strategy would otherwise run. + come from local `opencode-go` assistant costs in `opencode.db`, keyed by device-local calendar day. In Auto mode, + the web strategy's `enrichedUsageSnapshot` helper best-effort merges this local history alongside server-sourced + rolling/weekly percentages. Explicit Web mode never reads the local database, so it does not show cost history. From 9f82b769f2f6fe954418e587a25cc13269fa0550 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Jul 2026 20:07:04 +0100 Subject: [PATCH 7/9] fix: count OpenCode Go provider requests --- .../OpenCodeGoLocalUsageReader.swift | 53 ++++++++++--------- .../OpenCodeGoLocalUsageReaderTests.swift | 46 +++++++++++----- 2 files changed, 60 insertions(+), 39 deletions(-) diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift index 0f54881a90..272727a34a 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift @@ -96,7 +96,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) @@ -105,8 +107,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 } - let messageID = sqlite3_column_text(stmt, 2).map { String(cString: $0) } ?? "" - rows.append(UsageRow(createdMs: createdMs, cost: cost, messageID: messageID)) + let requestCount = max(1, Int(sqlite3_column_int64(stmt, 2))) + rows.append(UsageRow(createdMs: createdMs, cost: cost, requestCount: requestCount)) } return rows } @@ -133,7 +135,7 @@ public struct OpenCodeGoLocalUsageReader: Sendable { SELECT CAST(COALESCE(json_extract(data, '$.time.created'), time_created) AS INTEGER) AS createdMs, CAST(json_extract(data, '$.cost') AS REAL) AS cost, - id AS messageID + 1 AS requestCount FROM message WHERE json_valid(data) AND json_extract(data, '$.providerID') = 'opencode-go' @@ -142,47 +144,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, messageID - 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, - p.message_id AS messageID + 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 - /// Distinguishes distinct assistant turns so daily requestCount doesn't overcount a single - /// message whose cost is spread across multiple step-finish parts (messageAndPartUsageSQL's - /// per-part UNION ALL branch emits one row per part for such messages). - let messageID: String + /// 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 { @@ -280,14 +281,14 @@ public struct OpenCodeGoLocalUsageReader: Sendable { } let sinceStartOfDay = calendar.startOfDay(for: since) - var totals: [String: (cost: Double, messageIDs: Set)] = [:] + 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, messageIDs: []) + var bucket = totals[key] ?? (cost: 0, requestCount: 0) bucket.cost += row.cost - bucket.messageIDs.insert(row.messageID) + bucket.requestCount += row.requestCount totals[key] = bucket } @@ -298,7 +299,7 @@ public struct OpenCodeGoLocalUsageReader: Sendable { inputTokens: nil, outputTokens: nil, totalTokens: nil, - requestCount: bucket.messageIDs.count, + requestCount: bucket.requestCount, costUSD: bucket.cost, modelsUsed: nil, modelBreakdowns: nil) diff --git a/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift b/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift index c620800cac..5bd96d9f15 100644 --- a/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift +++ b/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift @@ -44,7 +44,7 @@ struct OpenCodeGoLocalUsageReaderTests { try Self.writeAuth(to: env.authURL) try Self.createDatabase(at: env.databaseURL) - // Noon UTC keeps these on the same calendar day across every real-world timezone offset. + // 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"), @@ -66,7 +66,11 @@ struct OpenCodeGoLocalUsageReaderTests { let now = Date(timeIntervalSince1970: TimeInterval(Self.ms("2026-03-06T15:00:00.000Z")) / 1000) let snapshot = try reader.fetch(now: now, historyDays: 30) - #expect(snapshot.daily.map(\.date) == ["2026-03-05", "2026-03-06"]) + 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) @@ -166,7 +170,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) } @@ -180,7 +184,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)) @@ -188,39 +197,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 counts messages not step finish part rows`() throws { + 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) - // One assistant turn with no message-level cost, costed via two separate step-finish - // parts (e.g. a multi-tool-call turn). This must still count as a single request. + 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("2026-03-06T11:00:00.000Z"), + createdMs: Self.ms(beforeMidnight), cost: nil) try Self.insertStepFinishPart( databaseURL: env.databaseURL, messageID: messageID, - createdMs: Self.ms("2026-03-06T11:00:00.000Z"), + createdMs: Self.ms(beforeMidnight), cost: 1.0) try Self.insertStepFinishPart( databaseURL: env.databaseURL, messageID: messageID, - createdMs: Self.ms("2026-03-06T11:05:00.000Z"), + createdMs: Self.ms(afterMidnight), cost: 2.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) - #expect(snapshot.daily.count == 1) - #expect(snapshot.daily.first?.costUSD == 3.0) + #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 @@ -369,6 +385,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 From 825e2f868d3107b21db7873dc1ebf94b67fa76ae Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Jul 2026 20:16:32 +0100 Subject: [PATCH 8/9] fix: isolate OpenCode Go history semantics --- .../PlanUtilizationHistoryChartMenuView.swift | 10 ++++++++- .../PlanUtilizationHistoryStore.swift | 1 + .../CodexBar/UsageStore+PlanUtilization.swift | 6 +++++- .../OpenCodeGoLocalUsageReader.swift | 13 ------------ .../OpenCodeGoProviderDescriptor.swift | 21 ++----------------- .../OpenCodeGoLocalUsageReaderTests.swift | 12 ----------- .../UsageStorePlanUtilizationTests.swift | 10 ++++----- docs/opencode.md | 10 ++++----- 8 files changed, 27 insertions(+), 56 deletions(-) diff --git a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift index 2ae2a7dcc6..d6ec4e6a70 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift @@ -250,7 +250,7 @@ struct PlanUtilizationHistoryChartMenuView: View { case .codex: if snapshot.primary != nil { names.insert(.session) } if snapshot.secondary != nil { names.insert(.weekly) } - case .claude, .opencodego: + case .claude: if snapshot.primary != nil { names.insert(.session) } if snapshot.secondary != nil { names.insert(.weekly) } if snapshot.tertiary != nil, @@ -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/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 06a9d50c0f..75d26ce540 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -574,10 +574,14 @@ extension UsageStore { for lane in projection.planUtilizationLanes { appendWindow(lane.window, name: lane.role) } - case .claude, .opencodego: + case .claude: 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/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift index 272727a34a..b9282fd313 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift @@ -65,15 +65,6 @@ public struct OpenCodeGoLocalUsageReader: Sendable { return Self.snapshot(rows: rows, now: now, historyDays: historyDays) } - /// Best-effort daily cost history, independent of `fetch()`'s window-percentage flow. The - /// opencode.ai web API has no daily-granularity endpoint, so the web fetch strategy calls this - /// to enrich its rolling/weekly percentages (server-authoritative) with local per-day cost - /// (device-only). Returns an empty array rather than throwing when local history is unavailable. - public func fetchDaily(now: Date = Date(), historyDays: Int = 30) -> [CostUsageDailyReport.Entry] { - guard let rows = try? self.readRows() else { return [] } - return Self.dailyEntries(rows: rows, now: now, historyDays: historyDays) - } - private func readRows() throws -> [UsageRow] { var db: OpaquePointer? guard sqlite3_open_v2(self.databaseURL.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { @@ -405,10 +396,6 @@ public struct OpenCodeGoLocalUsageReader: Sendable { public func fetch(now _: Date = Date(), historyDays _: Int = 30) throws -> OpenCodeGoUsageSnapshot { throw OpenCodeGoLocalUsageError.notSupported } - - public func fetchDaily(now _: Date = Date(), historyDays _: Int = 30) -> [CostUsageDailyReport.Entry] { - [] - } } #endif diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift index 921fdc6ad0..2bf0340f39 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift @@ -140,7 +140,7 @@ struct OpenCodeGoUsageFetchStrategy: ProviderFetchStrategy { workspaceIDOverride: workspaceOverride, includeZenBalance: context.includeOptionalUsage) return self.makeResult( - usage: Self.enrichedUsageSnapshot(snapshot, context: context), + usage: snapshot.toUsageSnapshot(), sourceLabel: "web") } catch OpenCodeGoUsageError.invalidCredentials where cookieSource != .manual { #if os(macOS) @@ -152,7 +152,7 @@ struct OpenCodeGoUsageFetchStrategy: ProviderFetchStrategy { workspaceIDOverride: workspaceOverride, includeZenBalance: context.includeOptionalUsage) return self.makeResult( - usage: Self.enrichedUsageSnapshot(snapshot, context: context), + usage: snapshot.toUsageSnapshot(), sourceLabel: "web") #else throw OpenCodeGoUsageError.invalidCredentials @@ -160,23 +160,6 @@ struct OpenCodeGoUsageFetchStrategy: ProviderFetchStrategy { } } - /// opencode.ai's web usage endpoint has no daily-granularity data, so the cost history chart - /// would stay empty for auto-mode users whose web session succeeds before the local fallback - /// ever runs. Best-effort blend in the on-device per-day cost history here instead — but only - /// when the local strategy could also have run in this source mode. Explicit `.web` mode - /// deliberately excludes `OpenCodeGoLocalUsageFetchStrategy` from the pipeline (see - /// `resolveStrategies`), so honor that choice here too rather than silently reading the local - /// database anyway. - private static func enrichedUsageSnapshot( - _ snapshot: OpenCodeGoUsageSnapshot, - context: ProviderFetchContext) -> UsageSnapshot - { - guard context.sourceMode != .web else { return snapshot.toUsageSnapshot() } - let daily = OpenCodeGoLocalUsageReader().fetchDaily(historyDays: context.costUsageHistoryDays) - guard !daily.isEmpty else { return snapshot.toUsageSnapshot() } - return snapshot.withDaily(daily).toUsageSnapshot() - } - func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool { guard context.sourceMode == .auto else { return false } return switch error { diff --git a/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift b/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift index 5bd96d9f15..61eff060e1 100644 --- a/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift +++ b/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift @@ -75,18 +75,6 @@ struct OpenCodeGoLocalUsageReaderTests { #expect(snapshot.daily.first?.requestCount == 1) #expect(snapshot.daily.last?.costUSD == 4.5) #expect(snapshot.daily.last?.requestCount == 2) - - let daily = reader.fetchDaily(now: now, historyDays: 30) - #expect(daily.map(\.date) == snapshot.daily.map(\.date)) - } - - @Test - func `fetchDaily returns no entries when local history is unavailable`() throws { - let env = try Self.makeEnvironment() - defer { try? FileManager.default.removeItem(at: env.root) } - - let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) - #expect(reader.fetchDaily().isEmpty) } @Test diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index 63f4b51700..0a6db50ab9 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -200,7 +200,7 @@ struct UsageStorePlanUtilizationTests { @MainActor @Test - func `opencodego history tabs include the monthly window as opus series`() { + 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), @@ -208,7 +208,7 @@ struct UsageStorePlanUtilizationTests { planSeries(name: .weekly, windowMinutes: 10080, entries: [ planEntry(at: Date(timeIntervalSince1970: 1_700_086_400), usedPercent: 57), ]), - planSeries(name: .opus, windowMinutes: 43200, entries: [ + planSeries(name: .monthly, windowMinutes: 43200, entries: [ planEntry(at: Date(timeIntervalSince1970: 1_700_086_400), usedPercent: 34), ]), ] @@ -224,7 +224,7 @@ struct UsageStorePlanUtilizationTests { provider: .opencodego, snapshot: snapshot) - #expect(model.visibleSeries == ["session:300", "weekly:10080", "opus:43200"]) + #expect(model.visibleSeries == ["session:300", "weekly:10080", "monthly:43200"]) #expect(model.selectedSeries == "session:300") } @@ -812,7 +812,7 @@ struct UsageStorePlanUtilizationTests { @MainActor @Test - func `record plan history stores opencodego monthly window as opus series`() async { + 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( @@ -835,7 +835,7 @@ struct UsageStorePlanUtilizationTests { 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: .opus, windowMinutes: 43200)?.entries.last?.usedPercent == 34) + #expect(findSeries(histories, name: .monthly, windowMinutes: 43200)?.entries.last?.usedPercent == 34) } @MainActor diff --git a/docs/opencode.md b/docs/opencode.md index f7000a0e4e..7d4c0b8903 100644 --- a/docs/opencode.md +++ b/docs/opencode.md @@ -29,9 +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. A successful web fetch best-effort adds local daily cost history; - authentication/setup failures fall back to quota windows derived 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. In Auto mode, - the web strategy's `enrichedUsageSnapshot` helper best-effort merges this local history alongside server-sourced - rolling/weekly percentages. Explicit Web mode never reads the local database, so it does not show cost history. + 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. From ee5d6d1b779b57f571f19fa99a7b7c67257756e9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Jul 2026 21:14:09 +0100 Subject: [PATCH 9/9] test: include OpenCode Go in spend sources --- Tests/CodexBarTests/SpendDashboardModelTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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