diff --git a/.github/pr-proof/3106-dashboard-snapshot.json b/.github/pr-proof/3106-dashboard-snapshot.json new file mode 100644 index 0000000000..c5b4d973e8 --- /dev/null +++ b/.github/pr-proof/3106-dashboard-snapshot.json @@ -0,0 +1,147 @@ +{ + "generatedAt" : "2026-08-23T05:39:37Z", + "host" : { + "codexBarVersion" : null, + "refreshIntervalSeconds" : 0 + }, + "providers" : [ + { + "cost" : null, + "credits" : null, + "display" : { + "accentColor" : "#49A3B0", + "priority" : "normal", + "sortKey" : 0 + }, + "enabled" : true, + "error" : { + "code" : 1, + "kind" : "provider", + "message" : "Network error: The operation couldn’t be completed. Operation not permitted" + }, + "id" : "codex", + "identity" : null, + "name" : "Codex", + "source" : "auto", + "status" : null, + "updatedAt" : "2026-08-23T05:39:36Z", + "windows" : [ + + ] + }, + { + "cost" : null, + "credits" : null, + "display" : { + "accentColor" : "#3B82F6", + "priority" : "normal", + "sortKey" : 10 + }, + "enabled" : true, + "error" : null, + "id" : "opencodego", + "identity" : null, + "name" : "OpenCode Go", + "source" : "local", + "status" : null, + "updatedAt" : "2026-08-23T05:39:32Z", + "windows" : [ + { + "kind" : "session", + "label" : "5-hour", + "remainingPercent" : 100, + "resetAt" : "2026-08-23T10:39:32Z", + "usedPercent" : 0 + }, + { + "kind" : "weekly", + "label" : "Weekly", + "remainingPercent" : 92.1, + "resetAt" : "2026-08-23T23:59:59Z", + "usedPercent" : 7.9 + }, + { + "kind" : "tertiary", + "label" : "Monthly", + "remainingPercent" : 96, + "resetAt" : "2026-09-20T04:11:23Z", + "usedPercent" : 4 + } + ] + }, + { + "cost" : null, + "credits" : null, + "display" : { + "accentColor" : "#60BA7E", + "priority" : "normal", + "sortKey" : 20 + }, + "enabled" : true, + "error" : { + "code" : 1, + "kind" : "provider", + "message" : "antigravity usage timed out" + }, + "id" : "antigravity", + "identity" : null, + "name" : "Antigravity", + "source" : "auto", + "status" : null, + "updatedAt" : "2026-08-23T05:39:37Z", + "windows" : [ + + ] + }, + { + "cost" : null, + "credits" : null, + "display" : { + "accentColor" : "#00BFA5", + "priority" : "normal", + "sortKey" : 30 + }, + "enabled" : true, + "error" : { + "code" : 1, + "kind" : "provider", + "message" : "The operation couldn’t be completed. Operation not permitted" + }, + "id" : "cursor", + "identity" : null, + "name" : "Cursor", + "source" : "auto", + "status" : null, + "updatedAt" : "2026-08-23T05:39:37Z", + "windows" : [ + + ] + }, + { + "cost" : null, + "credits" : null, + "display" : { + "accentColor" : "#527DF0", + "priority" : "normal", + "sortKey" : 40 + }, + "enabled" : true, + "error" : { + "code" : 1, + "kind" : "provider", + "message" : "The operation couldn’t be completed. Operation not permitted" + }, + "id" : "deepseek", + "identity" : null, + "name" : "DeepSeek", + "source" : "auto", + "status" : null, + "updatedAt" : "2026-08-23T05:39:37Z", + "windows" : [ + + ] + } + ], + "schemaVersion" : 1, + "staleAfterSeconds" : 180 +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 131355cc75..8d3c11eb10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## 0.54.2 — Unreleased + +### Performance +- OpenCodex: price each `usage.jsonl` entry once with a pre-resolved models.dev catalog and custom-pricing overlay, memoize day/hour buckets per calendar interval, and read the models.dev cache metadata with a plain `stat` instead of an extended-attribute read — the OpenCodex spend refresh drops from ~12 s to ~3 s on a 35k-entry log with identical output; existing cost caches are adopted on upgrade, not rebuilt (#3136). Thanks @olddonkey! +- Claude: web-cookie refresh works in ad-hoc development builds by routing cookie cache entries to process memory, keeping persistent Keychain storage for signed builds and OAuth credentials; cookie imports prefer Chrome and evaluate other browsers lazily (#3162). Thanks @Zihao-Qi! + +- Adaptive (agent-aware) refresh now detects ongoing Codex activity from the unified ChatGPT.app: the ChatGPT-bundled Codex is recognized at approved locations (signing-validated) and recent rollout writes hold the 5-minute active cadence (#3160, #3163). +- Antigravity: menu-bar quota refresh reuses an already signed-in `agy` CLI without requiring the desktop app, and logged-out/keyring-timeout states show sign-in guidance instead of "Launch Antigravity" (#3146, #3161). +- Codex: tokscale parity for local token counts — cached usage derives from the larger of `cached_input_tokens`/`cache_read_input_tokens`, out-of-order token_count events are detected field-level before watermark latching, and bare usage rows in non-event rollout lines parse (#3120). Thanks @Yuxin-Qiao! +- Added CHF (Swiss Franc) to the display currency options (#3149). +- Spend dashboard: silently refresh independent providers (Claude/Cursor) when their token publications update, bucket the activity heatmap with the configured IANA timezone, and stop coalescing display-affecting ownership and revision changes (#3106). Thanks @Yuxin-Qiao! +- Spend: add tokscale-compatible local readers for Cursor and Antigravity local history (#3113). Thanks @Yuxin-Qiao! +- Antigravity: map retired Flash wire IDs to their current tier, and count local conversations as an offline fallback when the desktop app is unavailable (#3119). Thanks @Yuxin-Qiao! +- Antigravity: reuse a signed-in `agy` for menu-bar quota refreshes without the desktop app, and report CLI sign-in failures instead of asking users to launch an unavailable app (#3146). +- Fixed single-quota menu-bar icons understating remaining usage: a provider's only meaningful quota now renders as one prominent meter instead of half an icon next to a reserved empty lane, including when it arrives in the secondary slot (#3154, #3155). Thanks @akshayprabhu200! +- Grok: report period-only CLI-proxy responses as unknown usage instead of 0% when Grok Build has hit its free limit, keeping identity and reset metadata (#3157, #3159). Thanks @anupamchugh! +- Refresh: detect ongoing Codex conversations hosted inside ChatGPT.app in Adaptive (agent-aware) mode without treating an idle open app as coding activity (#3160). +- OpenRouter: request the latest completed UTC day from the Activity API instead of the current date, fixing the HTTP 400 that suppressed spend history (#3133, #3138). Thanks @kiranmagic7! +- Qwen Cloud: restored Brave browser support in cookie import (#3148). Thanks @umutkeltek! +- CLI install: report success even when unrelated PATH directories are non-writable, while still listing genuine `codexbar` conflicts from earlier PATH entries (#3153). Thanks @yicone! +- Fixed the Codex cost card blanking Today and recent days when priority processing is in use: row-ownership evidence now accepts the trace database's tier classification instead of discarding whole (day, model) groups (#3150). Thanks @olddonkey! +- Stopped re-merging the Codex plan-utilization history with itself on every refresh and menu open — the legacy-bucket migration now runs only when a non-canonical bucket actually exists (#3141). Thanks @olddonkey! + ## 0.54.1 — 2026-08-21 ### Highlights diff --git a/Sources/CodexBar/IconRenderer.swift b/Sources/CodexBar/IconRenderer.swift index 774589b78e..e242290ba1 100644 --- a/Sources/CodexBar/IconRenderer.swift +++ b/Sources/CodexBar/IconRenderer.swift @@ -3,6 +3,23 @@ import CodexBarCore // swiftlint:disable:next type_body_length enum IconRenderer { + struct QuotaLayoutPolicy: Hashable { + let reservesMissingSecondaryLane: Bool + let treatsExhaustedSecondaryAsMissing: Bool + + static func provider(_ provider: UsageProvider) -> Self { + let presentation = ProviderDescriptorRegistry.descriptor(for: provider).presentation + return Self( + reservesMissingSecondaryLane: presentation.reservesMissingSecondaryIconLane, + treatsExhaustedSecondaryAsMissing: presentation.treatsExhaustedSecondaryIconWindowAsMissing) + } + + static func style(_ style: IconStyle) -> Self { + UsageProvider(rawValue: style.rawValue).map(self.provider) + ?? Self(reservesMissingSecondaryLane: false, treatsExhaustedSecondaryAsMissing: false) + } + } + private static let creditsCap: Double = 1000 private static let baseSize = NSSize(width: 18, height: 18) // Render to an 18×18 pt template (36×36 px at 2×) to match the system menu bar size. @@ -28,6 +45,11 @@ enum IconRenderer { private static let grid = PixelGrid(scale: outputScale) + static func fillWidthPixels(remaining: Double, rectWidth: Int) -> Int { + let clamped = max(0, min(remaining / 100, 1)) + return max(0, min(rectWidth, Int((CGFloat(rectWidth) * CGFloat(clamped)).rounded()))) + } + private struct IconCacheKey: Hashable { let primary: Int let weekly: Int @@ -36,6 +58,7 @@ enum IconRenderer { let style: Int let indicator: Int let hideCritters: Bool + let quotaLayoutPolicy: QuotaLayoutPolicy } private final class IconCacheStore: @unchecked Sendable { @@ -120,8 +143,10 @@ enum IconRenderer { wiggle: CGFloat = 0, tilt: CGFloat = 0, statusIndicator: ProviderStatusIndicator = .none, - hideCritters: Bool = false) -> NSImage + hideCritters: Bool = false, + quotaLayoutPolicy: QuotaLayoutPolicy? = nil) -> NSImage { + let quotaLayoutPolicy = quotaLayoutPolicy ?? .style(style) let shouldCache = blink <= 0.0001 && wiggle <= 0.0001 && tilt <= 0.0001 let render = { self.renderImage { @@ -178,8 +203,7 @@ enum IconRenderer { // Fill: clip to the capsule and paint a left-to-right rect so the progress edge is straight. if let remaining { - let clamped = max(0, min(remaining / 100, 1)) - let fillWidthPx = max(0, min(rectPx.w, Int((CGFloat(rectPx.w) * CGFloat(clamped)).rounded()))) + let fillWidthPx = Self.fillWidthPixels(remaining: remaining, rectWidth: rectPx.w) if fillWidthPx > 0 { NSGraphicsContext.current?.cgContext.saveGState() trackPath.addClip() @@ -638,8 +662,7 @@ enum IconRenderer { let providerPresentation = UsageProvider(rawValue: style.rawValue) .map { ProviderDescriptorRegistry.descriptor(for: $0).presentation } - let usesMissingSecondaryLayout = - providerPresentation?.treatsExhaustedSecondaryIconWindowAsMissing == true + let usesMissingSecondaryLayout = quotaLayoutPolicy.treatsExhaustedSecondaryAsMissing let effectiveWeeklyRemaining: Double? = { if usesMissingSecondaryLayout, let weeklyRemaining, weeklyRemaining <= 0 { return nil @@ -670,7 +693,22 @@ enum IconRenderer { let twistFactory = decorations.contains(.factory) let twistWarp = decorations.contains(.warp) - if weeklyAvailable { + if let bottomValue, bottomValue > 0, topValue == nil, + !quotaLayoutPolicy.reservesMissingSecondaryLane, + !usesMissingSecondaryLayout + { + // Some providers surface their only meaningful quota in the secondary slot. + drawBar( + rectPx: creditsRectPx, + remaining: bottomValue, + addNotches: twistNotches, + addFace: twistFace, + addGeminiTwist: twistGemini, + addAntigravityTwist: twistAntigravity, + addFactoryTwist: twistFactory, + addWarpTwist: twistWarp, + blink: blink) + } else if weeklyAvailable { // Normal: top=primary, bottom=secondary (bonus/weekly). drawBar( rectPx: topRectPx, @@ -693,8 +731,6 @@ enum IconRenderer { blink: blink) drawBar(rectPx: bottomRectPx, remaining: nil, alpha: 0.45) } else { - // Weekly missing (e.g. Claude enterprise): keep normal layout but - // dim the bottom track to indicate N/A. if topValue == nil, let ratio = creditsRatio { // Credits-only: show credits prominently (e.g. credits loaded before usage). drawBar( @@ -709,7 +745,22 @@ enum IconRenderer { addWarpTwist: twistWarp, blink: blink) drawBar(rectPx: creditsBottomRectPx, remaining: nil, alpha: 0.45) + } else if !quotaLayoutPolicy.reservesMissingSecondaryLane, let topValue { + // One meaningful quota should read as one meter. Reserving an unavailable second + // lane makes (for example) 46% remaining look like roughly 23% of the icon. + drawBar( + rectPx: creditsRectPx, + remaining: topValue, + addNotches: twistNotches, + addFace: twistFace, + addGeminiTwist: twistGemini, + addAntigravityTwist: twistAntigravity, + addFactoryTwist: twistFactory, + addWarpTwist: twistWarp, + blink: blink) } else { + // Missing secondary (for example Claude Enterprise): preserve the normal two-lane + // layout and dim the unavailable lane. drawBar( rectPx: topRectPx, remaining: topValue, @@ -765,7 +816,8 @@ enum IconRenderer { stale: stale, style: self.styleKey(style), indicator: self.indicatorKey(statusIndicator), - hideCritters: hideCritters) + hideCritters: hideCritters, + quotaLayoutPolicy: quotaLayoutPolicy) if let cached = self.cachedIcon(for: key) { return cached } diff --git a/Sources/CodexBar/MenuContent.swift b/Sources/CodexBar/MenuContent.swift index d7df29cafe..5462ec7368 100644 --- a/Sources/CodexBar/MenuContent.swift +++ b/Sources/CodexBar/MenuContent.swift @@ -262,6 +262,7 @@ struct StatusIconView: View { stale: self.store.isStale(provider: self.provider), style: self.store.style(for: self.provider), statusIndicator: self.store.statusIndicator(for: self.provider), - hideCritters: self.store.settings.menuBarHidesCritters) + hideCritters: self.store.settings.menuBarHidesCritters, + quotaLayoutPolicy: .provider(self.provider)) } } diff --git a/Sources/CodexBar/PlanUtilizationHistoryStore.swift b/Sources/CodexBar/PlanUtilizationHistoryStore.swift index 621749d503..ef7f0d83a9 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryStore.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryStore.swift @@ -40,6 +40,12 @@ struct PlanUtilizationSeriesHistory: Codable, Equatable, Sendable { let windowMinutes: Int let entries: [PlanUtilizationHistoryEntry] + private enum CodingKeys: String, CodingKey { + case name + case windowMinutes + case entries + } + init(name: PlanUtilizationSeriesName, windowMinutes: Int, entries: [PlanUtilizationHistoryEntry]) { self.name = name self.windowMinutes = windowMinutes @@ -56,6 +62,14 @@ struct PlanUtilizationSeriesHistory: Codable, Equatable, Sendable { } } + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let name = try container.decode(PlanUtilizationSeriesName.self, forKey: .name) + let windowMinutes = try container.decode(Int.self, forKey: .windowMinutes) + let entries = try container.decode([PlanUtilizationHistoryEntry].self, forKey: .entries) + self.init(name: name, windowMinutes: windowMinutes, entries: entries) + } + var latestCapturedAt: Date? { self.entries.last?.capturedAt } diff --git a/Sources/CodexBar/PreferencesAdvancedPane.swift b/Sources/CodexBar/PreferencesAdvancedPane.swift index ab21733bdf..4a5996cb18 100644 --- a/Sources/CodexBar/PreferencesAdvancedPane.swift +++ b/Sources/CodexBar/PreferencesAdvancedPane.swift @@ -87,35 +87,39 @@ extension AdvancedPane { "/opt/homebrew/bin/codexbar", ] - var results: [String] = [] + var installed: [String] = [] + var conflicts: [String] = [] + var failures: [String] = [] for dest in destinations { let dir = (dest as NSString).deletingLastPathComponent guard fm.fileExists(atPath: dir) else { continue } - guard fm.isWritableFile(atPath: dir) else { - results.append("No write access: \(dir)") - continue - } if fm.fileExists(atPath: dest) { if Self.isLink(atPath: dest, pointingTo: helperURL.path) { - results.append("Installed: \(dir)") + installed.append("Installed: \(dir)") } else { - results.append("Exists: \(dir)") + conflicts.append("Exists: \(dir)") } continue } + guard fm.isWritableFile(atPath: dir) else { + failures.append("No write access: \(dir)") + continue + } + do { try fm.createSymbolicLink(atPath: dest, withDestinationPath: helperURL.path) - results.append("Installed: \(dir)") + installed.append("Installed: \(dir)") } catch { - results.append("Failed: \(dir)") + failures.append("Failed: \(dir)") } } - self.cliStatus = results.isEmpty - ? L("no_writable_bin_dirs") - : results.joined(separator: " · ") + self.cliStatus = Self.cliInstallStatus( + installed: installed, + conflicts: conflicts, + failures: failures) } private static func isLink(atPath path: String, pointingTo destination: String) -> Bool { @@ -126,4 +130,17 @@ extension AdvancedPane { .path return resolved == destination } + + static func cliInstallStatus(installed: [String], conflicts: [String], failures: [String]) -> String { + if installed.isEmpty == false { + return (installed + conflicts).joined(separator: " · ") + } + if conflicts.isEmpty == false { + return (conflicts + failures).joined(separator: " · ") + } + if failures.isEmpty == false { + return failures.joined(separator: " · ") + } + return L("no_writable_bin_dirs") + } } diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index 892cb4d6ed..b1d07600da 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -90,6 +90,7 @@ enum PreferredCurrencyOption: String, CaseIterable, Identifiable { case twd = "TWD" case sgd = "SGD" case inr = "INR" + case chf = "CHF" var id: String { self.rawValue @@ -111,6 +112,7 @@ enum PreferredCurrencyOption: String, CaseIterable, Identifiable { case .twd: "TWD (NT$)" case .sgd: "SGD ($)" case .inr: "INR (₹)" + case .chf: "CHF (Fr.)" } } } diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index 7ae8500582..ff93717a7e 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -399,6 +399,7 @@ struct SpendDashboardPane: View { SpendDashboardPanel { SpendActivityHeatmapView( points: self.controller.model.tokenActivity, + calendar: self.settings.costUsageBucketCalendar, selectedDay: self.controller.selectedDay, onSelectDay: { day in self.controller.selectDay(day) diff --git a/Sources/CodexBar/SpendActivityHeatmap.swift b/Sources/CodexBar/SpendActivityHeatmap.swift index b27840a79f..b10383e347 100644 --- a/Sources/CodexBar/SpendActivityHeatmap.swift +++ b/Sources/CodexBar/SpendActivityHeatmap.swift @@ -354,9 +354,13 @@ enum SpendActivityWeekday { } enum SpendActivityDateFormatting { - static func mediumDateString(_ date: Date, locale: Locale? = nil) -> String { + static func mediumDateString(_ date: Date, calendar: Calendar? = nil, locale: Locale? = nil) -> String { let formatter = DateFormatter() formatter.locale = locale ?? codexBarLocalizedResourceLocale() + formatter.calendar = calendar ?? Calendar.current + if let calendar, let timeZone = calendar.timeZone as TimeZone? { + formatter.timeZone = timeZone + } formatter.dateStyle = .medium formatter.timeStyle = .none return formatter.string(from: date) @@ -364,14 +368,15 @@ enum SpendActivityDateFormatting { } enum SpendActivityAccessibility { - static func description(date: Date, value: String, locale: Locale? = nil) -> String { - "\(SpendActivityDateFormatting.mediumDateString(date, locale: locale)): \(value)" + static func description(date: Date, value: String, calendar: Calendar? = nil, locale: Locale? = nil) -> String { + "\(SpendActivityDateFormatting.mediumDateString(date, calendar: calendar, locale: locale)): \(value)" } } struct SpendActivityHeatmapView: View { let points: [SpendDashboardModel.TokenActivityPoint] let now: Date + let calendar: Calendar let selectedDay: Date? let onSelectDay: ((Date?) -> Void)? @@ -381,14 +386,16 @@ struct SpendActivityHeatmapView: View { init( points: [SpendDashboardModel.TokenActivityPoint], now: Date = Date(), + calendar: Calendar = .current, selectedDay: Date? = nil, onSelectDay: ((Date?) -> Void)? = nil) { self.points = points self.now = now + self.calendar = calendar self.selectedDay = selectedDay self.onSelectDay = onSelectDay - self._series = State(initialValue: SpendActivitySeries.make(from: points, now: now)) + self._series = State(initialValue: SpendActivitySeries.make(from: points, now: now, calendar: calendar)) } var body: some View { @@ -458,7 +465,10 @@ struct SpendActivityHeatmapView: View { } .frame(maxWidth: .infinity, alignment: .leading) .onChange(of: self.points) { _, points in - self.series = SpendActivitySeries.make(from: points, now: self.now) + self.series = SpendActivitySeries.make(from: points, now: self.now, calendar: self.calendar) + } + .onChange(of: self.calendar) { _, calendar in + self.series = SpendActivitySeries.make(from: self.points, now: self.now, calendar: calendar) } } @@ -666,7 +676,7 @@ private struct SpendActivityDailyGrid: View { title: self.series.isCovered[index] ? UsageFormatter.tokenCountString(self.series.daily[index]) : L("Unavailable"), - subtitle: SpendActivityDateFormatting.mediumDateString(date), + subtitle: SpendActivityDateFormatting.mediumDateString(date, calendar: self.series.calendar), width: width) .position( x: SpendActivityGridGeometry.tooltipCenterX( @@ -750,6 +760,8 @@ private struct SpendActivityDailyGrid: View { private func monthMarkers(pitch: CGFloat) -> [MonthMarker] { let formatter = DateFormatter() formatter.locale = codexBarLocalizedResourceLocale() + formatter.calendar = self.series.calendar + formatter.timeZone = self.series.calendar.timeZone formatter.dateFormat = "MMM" var markers: [MonthMarker] = [] var lastLabel = "" @@ -785,7 +797,8 @@ private struct SpendActivityDailyGrid: View { } private func accessibilityDescription(at index: Int, date: Date) -> String { - SpendActivityAccessibility.description(date: date, value: self.accessibilityTokenValue(at: index)) + SpendActivityAccessibility.description( + date: date, value: self.accessibilityTokenValue(at: index), calendar: self.series.calendar) } private func accessibilityTokenValue(at index: Int) -> String { @@ -886,7 +899,7 @@ private struct SpendActivityWeekGrid: View { title: self.activity.isCovered[col] ? UsageFormatter.tokenCountString(self.activity.values[col]) : L("Unavailable"), - subtitle: SpendActivityDateFormatting.mediumDateString(weekStart), + subtitle: SpendActivityDateFormatting.mediumDateString(weekStart, calendar: self.series.calendar), width: width) .position( x: SpendActivityGridGeometry.tooltipCenterX( @@ -940,7 +953,8 @@ private struct SpendActivityWeekGrid: View { let value = self.activity.isCovered[index] ? UsageFormatter.tokenCountString(self.activity.values[index]) : L("Unavailable") - return SpendActivityAccessibility.description(date: weekStart, value: value) + return SpendActivityAccessibility.description( + date: weekStart, value: value, calendar: self.series.calendar) } } diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index eebad58a05..11638755fb 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -468,7 +468,12 @@ enum SpendDashboardSource { try await withThrowingTaskGroup(of: (Int, String, SpendDashboardModel.ProviderInput?) .self) { group in + var pendingCount = 0 for (index, account) in pendingAccounts.enumerated() { + if pendingCount >= 3 { + _ = try await group.next() + pendingCount -= 1 + } group.addTask { let sourceID = "codex:\(account.id)" do { @@ -504,6 +509,7 @@ enum SpendDashboardSource { return (index, "codex:\(account.id)", nil) } } + pendingCount += 1 } var results: [(Int, String, SpendDashboardModel.ProviderInput?)] = [] for try await result in group { @@ -751,6 +757,51 @@ enum SpendDashboardSource { encoder.append(breakdown.priorityTokens) } } + encoder.append(snapshot.hourly.count) + for entry in snapshot.hourly { + encoder.append(entry.hour.timeIntervalSinceReferenceDate) + encoder.append(entry.totalTokens) + encoder.append(entry.costUSD) + } + encoder.append(snapshot.projects.count) + encoder.append(snapshot.sessions.count) + for project in snapshot.projects { + encoder.append(project.name) + encoder.append(project.path ?? "") + encoder.append(project.totalTokens) + encoder.append(project.totalCostUSD) + encoder.append(project.daily.count) + for entry in project.daily { + encoder.append(entry.date) + encoder.append(entry.costUSD) + encoder.append(entry.totalTokens) + encoder.append(entry.inputTokens) + encoder.append(entry.outputTokens) + } + if let breakdowns = project.modelBreakdowns { + encoder.append(breakdowns.count) + for breakdown in breakdowns { + encoder.append(breakdown.modelName) + encoder.append(breakdown.costUSD) + encoder.append(breakdown.totalTokens) + } + } else { + encoder.append(0) + } + } + for session in snapshot.sessions { + encoder.append(session.sessionID) + encoder.append(session.lastActivity.timeIntervalSinceReferenceDate) + encoder.append(session.totalTokens) + encoder.append(session.costUSD) + encoder.append(session.requestCount) + encoder.append(session.modelBreakdowns.count) + for breakdown in session.modelBreakdowns { + encoder.append(breakdown.modelName) + encoder.append(breakdown.costUSD) + encoder.append(breakdown.totalTokens) + } + } return encoder.finalize() } @@ -1125,10 +1176,28 @@ final class SpendDashboardController { Self.isDisplayOnlyConfigurationChange(from: previousConfiguration, to: configuration) { self.configuration = configuration + // Provider-specific by design: bucket calendar change renormalizes selected day atomically with new config. + if let selectedDay = self.selectedDay { + let newCalendar = CostUsageBucketTimeZone.calendar(identifier: configuration.bucketTimeZoneIdentifier) + let normalized = newCalendar.startOfDay(for: selectedDay) + if normalized != selectedDay { + self.selectedDay = normalized + } + } self.rebuildModel() return } self.configuration = configuration + // Normalize selected day when bucket timezone changes, atomically with new configuration. + if let selectedDay = self.selectedDay, + previousConfiguration?.bucketTimeZoneIdentifier != configuration.bucketTimeZoneIdentifier + { + let newCalendar = CostUsageBucketTimeZone.calendar(identifier: configuration.bucketTimeZoneIdentifier) + let normalized = newCalendar.startOfDay(for: selectedDay) + if normalized != selectedDay { + self.selectedDay = normalized + } + } if self.isRefreshing || self.phase.manualRefreshOutstanding, let previousConfiguration, Self.sameSourceOwnership(previousConfiguration, configuration) diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 2ad7f1b211..5bb026843b 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -412,7 +412,8 @@ extension StatusItemController { wiggle: wiggle, tilt: tilt, statusIndicator: statusIndicator, - hideCritters: self.settings.menuBarHidesCritters) + hideCritters: self.settings.menuBarHidesCritters, + quotaLayoutPolicy: .provider(primaryProvider)) self.setButtonContent( image: warningFlash ? Self.quotaWarningFlashImage(base: image) : image, title: nil, @@ -636,7 +637,8 @@ extension StatusItemController { wiggle: wiggle, tilt: tilt, statusIndicator: statusIndicator, - hideCritters: self.settings.menuBarHidesCritters) + hideCritters: self.settings.menuBarHidesCritters, + quotaLayoutPolicy: .provider(provider)) self.setButtonContent( image: warningFlash ? Self.quotaWarningFlashImage(base: image) : image, title: nil, diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index fc2df6a124..f467a3c51f 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1432,7 +1432,8 @@ extension StatusItemController { wiggle: 0, tilt: 0, statusIndicator: indicator, - hideCritters: self.settings.menuBarHidesCritters) + hideCritters: self.settings.menuBarHidesCritters, + quotaLayoutPolicy: .provider(provider)) image.isTemplate = true return image } diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 6ac3d3dc76..a3eb54d74e 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -373,8 +373,10 @@ extension UsageStore { let canonicalWindowMinutes = sample.name.canonicalWindowMinutes(sample.windowMinutes) let key = PlanUtilizationSeriesKey(name: sample.name, windowMinutes: canonicalWindowMinutes) if let existingHistory = historiesByKey[key] { - guard let updatedEntries = self.updatedPlanUtilizationEntries( - existingEntries: existingHistory.entries, + var updatedEntries = existingHistory.entries + self.assertPlanUtilizationEntriesSorted(updatedEntries) + guard self.updatedPlanUtilizationEntries( + existingEntries: &updatedEntries, entry: sample.entry) else { continue @@ -411,28 +413,72 @@ extension UsageStore { } private nonisolated static func updatedPlanUtilizationEntries( - existingEntries: [PlanUtilizationHistoryEntry], - entry: PlanUtilizationHistoryEntry) -> [PlanUtilizationHistoryEntry]? + existingEntries: inout [PlanUtilizationHistoryEntry], + entry: PlanUtilizationHistoryEntry) -> Bool { - var entries = existingEntries - let insertionIndex = entries.firstIndex(where: { $0.capturedAt > entry.capturedAt }) ?? entries.endIndex + let insertionIndex = self.planUtilizationEntryInsertionIndex( + entries: existingEntries, + capturedAt: entry.capturedAt) let sampleHourBucket = self.planUtilizationHourBucket(for: entry.capturedAt) let sameHourRange = self.planUtilizationHourRange( - entries: entries, + entries: existingEntries, insertionIndex: insertionIndex, hourBucket: sampleHourBucket) - let existingHourEntries = Array(entries[sameHourRange]) + let existingHourEntries = Array(existingEntries[sameHourRange]) let canonicalHourEntries = self.canonicalPlanUtilizationHourEntries( existingHourEntries: existingHourEntries, incomingEntry: entry) - guard canonicalHourEntries != existingHourEntries else { return nil } - entries.replaceSubrange(sameHourRange, with: canonicalHourEntries) + guard canonicalHourEntries != existingHourEntries else { return false } + existingEntries.replaceSubrange(sameHourRange, with: canonicalHourEntries) - if entries.count > self.planUtilizationMaxSamples { - entries.removeFirst(entries.count - self.planUtilizationMaxSamples) + if existingEntries.count > self.planUtilizationMaxSamples { + existingEntries.removeFirst(existingEntries.count - self.planUtilizationMaxSamples) } - return entries + return true + } + + /// The insertion search assumes `capturedAt` order. Checked once per merged series rather than per entry — + /// a per-entry check would reintroduce the O(n) scan this insertion path exists to remove. + private nonisolated static func assertPlanUtilizationEntriesSorted( + _ entries: [PlanUtilizationHistoryEntry]) + { + #if DEBUG + assert( + zip(entries, entries.dropFirst()).allSatisfy { $0.capturedAt <= $1.capturedAt }, + "plan-utilization entries must be sorted by capturedAt before insertion") + #endif + } + + private nonisolated static func planUtilizationEntryInsertionIndex( + entries: [PlanUtilizationHistoryEntry], + capturedAt: Date) -> Int + { + guard let lastCapturedAt = entries.last?.capturedAt else { + return entries.endIndex + } + if lastCapturedAt <= capturedAt { + return entries.endIndex + } + return self.planUtilizationEntryUpperBound(entries: entries, capturedAt: capturedAt) + } + + /// First index i such that entries[i].capturedAt > capturedAt (endIndex if none). + private nonisolated static func planUtilizationEntryUpperBound( + entries: [PlanUtilizationHistoryEntry], + capturedAt: Date) -> Int + { + var low = entries.startIndex + var high = entries.endIndex + while low < high { + let mid = low + ((high - low) / 2) + if entries[mid].capturedAt > capturedAt { + high = mid + } else { + low = mid + 1 + } + } + return low } #if DEBUG @@ -440,7 +486,18 @@ extension UsageStore { existingEntries: [PlanUtilizationHistoryEntry], entry: PlanUtilizationHistoryEntry) -> [PlanUtilizationHistoryEntry]? { - self.updatedPlanUtilizationEntries(existingEntries: existingEntries, entry: entry) + var entries = existingEntries + guard self.updatedPlanUtilizationEntries(existingEntries: &entries, entry: entry) else { + return nil + } + return entries + } + + nonisolated static func _planUtilizationEntryUpperBoundForTesting( + entries: [PlanUtilizationHistoryEntry], + capturedAt: Date) -> Int + { + self.planUtilizationEntryUpperBound(entries: entries, capturedAt: capturedAt) } nonisolated static func _updatedPlanUtilizationHistoriesForTesting( @@ -1202,6 +1259,7 @@ extension UsageStore { var historiesToMerge: [[PlanUtilizationSeriesHistory]] = [] let scopedRawKeys = Array(providerBuckets.accounts.keys) var legacyRawKeysToRemove: [String] = [] + var hasForeignHistoryToMerge = false for rawKey in scopedRawKeys { let owner = CodexHistoryOwnership.classifyPersistedKey( @@ -1219,6 +1277,7 @@ extension UsageStore { historiesToMerge.append(accountHistories) if rawKey != canonicalKey { legacyRawKeysToRemove.append(rawKey) + hasForeignHistoryToMerge = true } } } @@ -1232,6 +1291,7 @@ extension UsageStore { { historiesToMerge.append(opaqueHistories) legacyRawKeysToRemove.append(recoverableOpaqueRawKey) + hasForeignHistoryToMerge = true } if shouldAdoptUnscopedHistory, @@ -1245,9 +1305,14 @@ extension UsageStore { { historiesToMerge.append(providerBuckets.unscoped) providerBuckets.unscoped = [] + hasForeignHistoryToMerge = true } - guard !historiesToMerge.isEmpty else { return canonicalKey } + // Canonical-only contribution is not a migration; re-merging it with itself is quadratic. + // Skipping also skips incidental re-canonicalization of already-canonical series (duplicate + // (name, windowMinutes) collapse and per-hour peak replay). Repairing legacy data belongs + // at load time, not on every refresh and menu open. + guard hasForeignHistoryToMerge else { return canonicalKey } for rawKey in legacyRawKeysToRemove { providerBuckets.accounts.removeValue(forKey: rawKey) } @@ -1523,29 +1588,29 @@ extension UsageStore { provider _: UsageProvider, histories: [[PlanUtilizationSeriesHistory]]) -> [PlanUtilizationSeriesHistory] { - var mergedByKey: [PlanUtilizationSeriesKey: PlanUtilizationSeriesHistory] = [:] + var mergedEntriesByKey: [PlanUtilizationSeriesKey: [PlanUtilizationHistoryEntry]] = [:] for historyGroup in histories { for history in historyGroup { let key = PlanUtilizationSeriesKey(name: history.name, windowMinutes: history.windowMinutes) - let existingEntries = mergedByKey[key]?.entries ?? [] - var mergedEntries = existingEntries + var mergedEntries = mergedEntriesByKey[key] ?? [] + self.assertPlanUtilizationEntriesSorted(mergedEntries) for entry in history.entries.sorted(by: { $0.capturedAt < $1.capturedAt }) { - if let updatedEntries = self.updatedPlanUtilizationEntries( - existingEntries: mergedEntries, + _ = self.updatedPlanUtilizationEntries( + existingEntries: &mergedEntries, entry: entry) - { - mergedEntries = updatedEntries - } } - mergedByKey[key] = PlanUtilizationSeriesHistory( - name: history.name, - windowMinutes: history.windowMinutes, - entries: mergedEntries) + mergedEntriesByKey[key] = mergedEntries } } - return mergedByKey.values.sorted { lhs, rhs in + return mergedEntriesByKey.map { key, entries in + PlanUtilizationSeriesHistory( + name: key.name, + windowMinutes: key.windowMinutes, + entries: entries) + } + .sorted { lhs, rhs in if lhs.windowMinutes != rhs.windowMinutes { return lhs.windowMinutes < rhs.windowMinutes } diff --git a/Sources/CodexBar/UsageStore+SpendDashboardPublication.swift b/Sources/CodexBar/UsageStore+SpendDashboardPublication.swift index 508b937678..f6a14d5220 100644 --- a/Sources/CodexBar/UsageStore+SpendDashboardPublication.swift +++ b/Sources/CodexBar/UsageStore+SpendDashboardPublication.swift @@ -86,6 +86,12 @@ extension UsageStore { self.scheduleDebouncedTokenPublicationSync() } + #if DEBUG + var _test_hasPendingSpendDashboardTokenPublicationSync: Bool { + self.sharedSpendDashboardTokenPublicationDebounceTask != nil + } + #endif + private func scheduleDebouncedTokenPublicationSync() { self.sharedSpendDashboardTokenPublicationDebounceTask?.cancel() let delay: Duration = self.startupBehavior.automaticallyStartsBackgroundWork diff --git a/Sources/CodexBar/UsageStore+SpendDashboardTokenCost.swift b/Sources/CodexBar/UsageStore+SpendDashboardTokenCost.swift index 5103b7749c..6b55bc227c 100644 --- a/Sources/CodexBar/UsageStore+SpendDashboardTokenCost.swift +++ b/Sources/CodexBar/UsageStore+SpendDashboardTokenCost.swift @@ -178,6 +178,7 @@ extension UsageStore { publicationRevision: self.spendDashboardTokenSnapshotPublicationRevision(for: provider), providerConfigRevision: self.settings.providerConfigRevision(for: provider), scopeSignature: self.spendDashboardTokenSnapshotScopeSignature(for: provider)) + self.synchronizeSharedSpendDashboardAfterTokenPublication(for: provider) } private func spendDashboardTokenRefreshPublicationIsCurrent( diff --git a/Sources/CodexBarCore/AgentSession.swift b/Sources/CodexBarCore/AgentSession.swift index 372d552887..57a3b229c0 100644 --- a/Sources/CodexBarCore/AgentSession.swift +++ b/Sources/CodexBarCore/AgentSession.swift @@ -296,6 +296,28 @@ public enum AgentPSOutputParser { } } + static func chatGPTCodexAppServerExecutable( + in records: [AgentProcessRecord], + homeDirectory: URL) -> String? + { + let allowedPaths = Set([ + URL(fileURLWithPath: "/Applications/ChatGPT.app/Contents/Resources/codex") + .standardizedFileURL.path, + homeDirectory.appendingPathComponent("Applications/ChatGPT.app/Contents/Resources/codex") + .standardizedFileURL.path, + ]) + + return records.lazy.compactMap { record -> String? in + guard record.executableBasename.lowercased() == AgentSession.Provider.codex.rawValue, + self.arguments(record.command).contains("app-server"), + let executable = record.command.split(whereSeparator: \ .isWhitespace).first + else { return nil } + + let path = URL(fileURLWithPath: String(executable)).standardizedFileURL.path + return allowedPaths.contains(path) ? path : nil + }.first + } + private static func arguments(_ command: String) -> [String] { command.split(whereSeparator: \ .isWhitespace).dropFirst().map(String.init) } @@ -320,8 +342,9 @@ public enum AgentPSOutputParser { private static func isCodexAgentExecutable(_ command: String) -> Bool { let lowercased = command.lowercased() guard lowercased.contains(".app/") else { return true } - return lowercased.hasPrefix("/applications/codex.app/contents/resources/codex ") || - lowercased.hasPrefix("/applications/codex.app/contents/resources/codex\t") + let executable = lowercased.split(whereSeparator: \ .isWhitespace).first.map(String.init) + return executable == "/applications/codex.app/contents/resources/codex" || + executable == "/applications/chatgpt.app/contents/resources/codex" } private static func isClaudeAgentExecutable(_ command: String) -> Bool { diff --git a/Sources/CodexBarCore/BrowserCookieAccessGate.swift b/Sources/CodexBarCore/BrowserCookieAccessGate.swift index e6e732cb5a..80d19846c0 100644 --- a/Sources/CodexBarCore/BrowserCookieAccessGate.swift +++ b/Sources/CodexBarCore/BrowserCookieAccessGate.swift @@ -60,6 +60,9 @@ public enum BrowserCookieAccessGate { private static let log = CodexBarLog.logger(LogCategories.browserCookieGate) @TaskLocal private static var explicitRetryScope: ExplicitRetryScope? @TaskLocal private static var deniedBrowsersForTesting: [Browser]? + #if DEBUG + @TaskLocal private static var shouldAttemptOverrideForTesting: Bool? + #endif static let allowTestCookieAccessEnvironmentKey = "CODEXBAR_ALLOW_TEST_BROWSER_COOKIE_ACCESS" @@ -84,6 +87,11 @@ public enum BrowserCookieAccessGate { } public static func shouldAttempt(_ browser: Browser, now: Date = Date()) -> Bool { + #if DEBUG + if let shouldAttemptOverrideForTesting { + return shouldAttemptOverrideForTesting + } + #endif guard browser.usesKeychainForCookieDecryption else { return true } guard !KeychainAccessGate.isDisabled else { return false } guard ProviderInteractionContext.current == .userInitiated else { @@ -167,6 +175,17 @@ public enum BrowserCookieAccessGate { } } + #if DEBUG + static func withShouldAttemptOverrideForTesting( + _ result: Bool?, + operation: () throws -> T) rethrows -> T + { + try self.$shouldAttemptOverrideForTesting.withValue(result) { + try operation() + } + } + #endif + static func operationPreservingAccessContext( _ operation: @escaping @Sendable () throws -> T) -> @Sendable () throws -> T { diff --git a/Sources/CodexBarCore/BrowserCookieImportOrder.swift b/Sources/CodexBarCore/BrowserCookieImportOrder.swift index 7419dffbde..3ff6a465d0 100644 --- a/Sources/CodexBarCore/BrowserCookieImportOrder.swift +++ b/Sources/CodexBarCore/BrowserCookieImportOrder.swift @@ -15,13 +15,17 @@ extension [Browser] { /// /// This is intentionally stricter than "app installed": it aims to avoid unnecessary Keychain prompts. public func cookieImportCandidates(using detection: BrowserDetection) -> [Browser] { - let candidates = self.filter { browser in + Array(self.lazyCookieImportCandidates(using: detection)) + } + + /// Lazily filters browser sources so callers can stop after the first successful cookie import. + func lazyCookieImportCandidates(using detection: BrowserDetection) -> some Sequence { + self.lazy.filter { browser in if KeychainAccessGate.isDisabled, browser.usesKeychainForCookieDecryption { return false } - return detection.isCookieSourceAvailable(browser) + return detection.isCookieSourceAvailable(browser) && BrowserCookieAccessGate.shouldAttempt(browser) } - return candidates.filter { BrowserCookieAccessGate.shouldAttempt($0) } } /// Filters a browser list to sources with usable profile data on disk. diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index cc58477c52..473dd35923 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -1,3 +1,4 @@ +// swiftlint:disable file_length import Foundation public enum CostUsageError: LocalizedError, Sendable { @@ -425,14 +426,43 @@ public struct CostUsageFetcher: Sendable { let clampedHistoryDays = max(1, min(365, historyDays)) - if let remoteSnapshot = try await self.loadRemoteTokenSnapshot( + var remoteSnapshot: CostUsageTokenSnapshot? + var remoteError: Error? + // Provider-specific by design: Cursor may fall back to local CSV when its remote dashboard is unavailable. + do { + remoteSnapshot = try await self.loadRemoteTokenSnapshot( + provider: provider, + environment: environment, + now: now, + historyDays: clampedHistoryDays, + cursorCookieHeaderOverride: cursorCookieHeaderOverride) + } catch { + if provider != .cursor { + throw error + } + remoteError = error + } + if let remoteSnapshot { + return remoteSnapshot + } + + // Provider-specific by design: Cursor and Antigravity local readers backfill providers without remote history. + let fallbackCalendar = Self.resolvedScannerOptions( + overrideScannerOptions, provider: provider, - environment: environment, - now: now, - historyDays: clampedHistoryDays, - cursorCookieHeaderOverride: cursorCookieHeaderOverride) + codexHomePath: codexHomePath).calendar + if provider == .cursor, let local = await self.loadCursorLocalSnapshot( + now: now, historyDays: clampedHistoryDays, calendar: fallbackCalendar) { - return remoteSnapshot + return local + } + if let remoteError { + throw remoteError + } + if provider == .antigravity, let local = await self.loadAntigravityLocalSnapshot( + now: now, historyDays: clampedHistoryDays, calendar: fallbackCalendar) + { + return local } var options = Self.resolvedScannerOptions( @@ -1120,6 +1150,74 @@ public struct CostUsageFetcher: Sendable { } #endif + private static func loadCursorLocalSnapshot( + now: Date, + historyDays: Int, + calendar: Calendar = .current) async -> CostUsageTokenSnapshot? + { + let paths = CursorLocalCSVReader.cachedCSVPaths() + guard !paths.isEmpty else { return nil } + var allRows: [CursorLocalCSVReader.Row] = [] + for url in paths { + allRows.append(contentsOf: CursorLocalCSVReader.parseFile(at: url)) + } + guard !allRows.isEmpty else { return nil } + let full = CursorLocalCSVReader.makeDailyReport(from: allRows, calendar: calendar, now: now) + let cal = calendar + let since = cal.date(byAdding: .day, value: -(historyDays - 1), to: cal.startOfDay(for: now)) ?? now + let sinceKey = CostUsageLocalDay.key(from: since, calendar: cal) + let nowKey = CostUsageLocalDay.key(from: now, calendar: cal) + let filtered = full.data.filter { $0.date >= sinceKey && $0.date <= nowKey } + guard !filtered.isEmpty else { return nil } + let costValues = filtered.compactMap(\.costUSD) + let totalCost: Double? = costValues.isEmpty ? nil : costValues.reduce(0, +) + let totalTokens = filtered.compactMap(\.totalTokens).reduce(0, +) + let filteredSummary: CostUsageDailyReport.Summary = .init( + totalInputTokens: nil, + totalOutputTokens: nil, + totalTokens: totalTokens, + totalCostUSD: totalCost) + let daily = CostUsageDailyReport(data: filtered, summary: filteredSummary) + return Self.tokenSnapshot( + from: daily, + now: now, + historyDays: historyDays, + useCurrentLocalDayForSession: true, + calendar: cal, + costProvenance: .listPriceEstimate) + } + + private static func loadAntigravityLocalSnapshot( + now: Date, + historyDays: Int, + calendar: Calendar = .current) async -> CostUsageTokenSnapshot? + { + let cal = calendar + let report = AntigravityLocalReader.makeDailyReport(calendar: cal) + guard !report.data.isEmpty else { return nil } + let since = cal.date(byAdding: .day, value: -(historyDays - 1), to: cal.startOfDay(for: now)) ?? now + let sinceKey = CostUsageLocalDay.key(from: since, calendar: cal) + let nowKey = CostUsageLocalDay.key(from: now, calendar: cal) + let filtered = report.data.filter { $0.date >= sinceKey && $0.date <= nowKey } + guard !filtered.isEmpty else { return nil } + let costValues = filtered.compactMap(\.costUSD) + let totalCost: Double? = costValues.isEmpty ? nil : costValues.reduce(0, +) + let totalTokens = filtered.compactMap(\.totalTokens).reduce(0, +) + let filteredSummary: CostUsageDailyReport.Summary = .init( + totalInputTokens: nil, + totalOutputTokens: nil, + totalTokens: totalTokens, + totalCostUSD: totalCost) + let daily = CostUsageDailyReport(data: filtered, summary: filteredSummary) + return Self.tokenSnapshot( + from: daily, + now: now, + historyDays: historyDays, + useCurrentLocalDayForSession: true, + calendar: cal, + costProvenance: .unknown) + } + static func tokenSnapshot( from daily: CostUsageDailyReport, now: Date, diff --git a/Sources/CodexBarCore/CurrencyExchange.swift b/Sources/CodexBarCore/CurrencyExchange.swift index a03f8ff218..1908c2f2c3 100644 --- a/Sources/CodexBarCore/CurrencyExchange.swift +++ b/Sources/CodexBarCore/CurrencyExchange.swift @@ -15,7 +15,7 @@ public final class CurrencyExchange: @unchecked Sendable { /// All currency codes supported by the converter. public static let supportedCurrencies: [String] = [ - "USD", "GBP", "EUR", "CZK", "CNY", "JPY", "KRW", "CAD", "AUD", "HKD", "TWD", "SGD", "INR", + "USD", "GBP", "EUR", "CZK", "CNY", "JPY", "KRW", "CAD", "AUD", "HKD", "TWD", "SGD", "INR", "CHF", ] private let lock = NSLock() @@ -35,6 +35,7 @@ public final class CurrencyExchange: @unchecked Sendable { "TWD": 32.30, "SGD": 1.34, "INR": 84.50, + "CHF": 0.80, ] private var lastFetchTime: Date? diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index db7b5fdd77..580ca61bca 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "3c984b655688593f" + static let value = "cfd84d13ad7d4cfa" } diff --git a/Sources/CodexBarCore/KeychainAccessGate.swift b/Sources/CodexBarCore/KeychainAccessGate.swift index 3fa2c505a8..ffd6462d2c 100644 --- a/Sources/CodexBarCore/KeychainAccessGate.swift +++ b/Sources/CodexBarCore/KeychainAccessGate.swift @@ -7,6 +7,9 @@ public enum KeychainAccessGate { private static let flagKey = "debugDisableKeychainAccess" static let disableAccessEnvironmentKey = "CODEXBAR_DISABLE_KEYCHAIN_ACCESS" @TaskLocal private static var taskOverrideValue: Bool? + #if DEBUG + @TaskLocal private static var storedOverrideForTesting: Bool? + #endif // All mutable gate state and mirror writes share this lock. Resolve the effective value with // `isDisabledLocked()` instead of recursively entering through the public getter. private static let stateLock = NSLock() @@ -38,6 +41,9 @@ public enum KeychainAccessGate { if Self.forcesDisabledUnderTests { return true } #endif if self.processForceDisabledReason != nil { return true } + #if DEBUG + if let storedOverrideForTesting { return storedOverrideForTesting } + #endif if let overrideValue { return overrideValue } if UserDefaults.standard.bool(forKey: Self.flagKey) { return true } if let shared = AppGroupSupport.sharedDefaults(), shared.bool(forKey: Self.flagKey) { return true } @@ -55,6 +61,9 @@ public enum KeychainAccessGate { if let taskOverrideValue { return taskOverrideValue } if self.isDisabledByEnvironment() { return true } if self.processForceDisabledReason != nil { return true } + #if DEBUG + if let storedOverrideForTesting { return storedOverrideForTesting } + #endif if let overrideValue { return overrideValue } if UserDefaults.standard.bool(forKey: Self.flagKey) { return true } if let shared = AppGroupSupport.sharedDefaults(), shared.bool(forKey: Self.flagKey) { return true } @@ -115,8 +124,25 @@ public enum KeychainAccessGate { } } + #if DEBUG + static func withStoredOverrideForTesting( + _ disabled: Bool?, + operation: () throws -> T) rethrows -> T + { + try self.$storedOverrideForTesting.withValue(disabled) { + try operation() + } + } + #endif + static var currentOverrideForTesting: Bool? { + #if DEBUG + self.taskOverrideValue + ?? self.storedOverrideForTesting + ?? self.stateLock.withLock { self.overrideValue } + #else self.taskOverrideValue ?? self.stateLock.withLock { self.overrideValue } + #endif } #if DEBUG diff --git a/Sources/CodexBarCore/KeychainAccessPreflight.swift b/Sources/CodexBarCore/KeychainAccessPreflight.swift index f9c78fa1d1..577da059eb 100644 --- a/Sources/CodexBarCore/KeychainAccessPreflight.swift +++ b/Sources/CodexBarCore/KeychainAccessPreflight.swift @@ -1,6 +1,7 @@ +import Foundation + #if os(macOS) import Darwin -import Foundation import LocalAuthentication import Security #endif @@ -90,7 +91,32 @@ public enum KeychainAccessPreflight { case failure(Int) } + private struct GenericPasswordKey: Hashable { + let service: String + let account: String? + } + + private final class GenericPasswordCheckMemo: @unchecked Sendable { + private let lock = NSLock() + private var outcomes: [GenericPasswordKey: Outcome] = [:] + + func outcome( + for key: GenericPasswordKey, + check: () -> Outcome) -> Outcome + { + self.lock.lock() + defer { self.lock.unlock() } + if let outcome = self.outcomes[key] { + return outcome + } + let outcome = check() + self.outcomes[key] = outcome + return outcome + } + } + private static let log = CodexBarLog.logger(LogCategories.keychainPreflight) + @TaskLocal private static var genericPasswordCheckMemo: GenericPasswordCheckMemo? #if DEBUG final class CheckGenericPasswordOverrideStore: @unchecked Sendable { @@ -131,7 +157,27 @@ public enum KeychainAccessPreflight { } #endif + /// Reuses identical no-UI generic-password preflights within one synchronous operation. + /// The scope is deliberately short-lived because Keychain items and their ACLs can change. + public static func withMemoizedGenericPasswordChecks( + _ operation: () throws -> T) rethrows -> T + { + try self.$genericPasswordCheckMemo.withValue(GenericPasswordCheckMemo()) { + try operation() + } + } + public static func checkGenericPassword(service: String, account: String?) -> Outcome { + let key = GenericPasswordKey(service: service, account: account) + if let memo = self.genericPasswordCheckMemo { + return memo.outcome(for: key) { + self.checkGenericPasswordUncached(service: service, account: account) + } + } + return self.checkGenericPasswordUncached(service: service, account: account) + } + + private static func checkGenericPasswordUncached(service: String, account: String?) -> Outcome { #if os(macOS) #if DEBUG if let override = self.taskCheckGenericPasswordOverrideStore { diff --git a/Sources/CodexBarCore/KeychainCacheStore+ApplicationPaths.swift b/Sources/CodexBarCore/KeychainCacheStore+ApplicationPaths.swift index e5617c5e29..202e2ec8ae 100644 --- a/Sources/CodexBarCore/KeychainCacheStore+ApplicationPaths.swift +++ b/Sources/CodexBarCore/KeychainCacheStore+ApplicationPaths.swift @@ -1,6 +1,47 @@ import Foundation +#if os(macOS) +import Security +#endif extension KeychainCacheStore { + #if DEBUG + @TaskLocal static var bundledAdHocProcessOverrideForTesting: Bool? + #endif + + /// Ad-hoc app rebuilds cannot satisfy legacy Keychain ACLs created by an earlier executable identity. + /// Keep cookie caches process-local for those builds; certificate-signed development and release apps + /// retain the persistent Keychain cache. + static var isBundledAdHocProcess: Bool { + #if DEBUG + if let override = self.bundledAdHocProcessOverrideForTesting { + return override + } + #endif + return self.detectedBundledAdHocProcess + } + + private static let detectedBundledAdHocProcess: Bool = { + #if os(macOS) + guard let appBundle = Self.appBundleURL(containing: Bundle.main.bundleURL) + ?? Bundle.main.executableURL.flatMap(Self.appBundleURL(containing:)) + else { return false } + return !Self.hasCodeSigningCertificate(at: appBundle) + #else + return false + #endif + }() + + #if DEBUG + static func withBundledAdHocProcessForTesting( + _ enabled: Bool, + operation: () throws -> T) rethrows -> T + { + try self.$bundledAdHocProcessOverrideForTesting.withValue(enabled) { + try operation() + } + } + #endif + static func trustedApplicationPathsForCacheAccess( bundleURL: URL = Bundle.main.bundleURL, executableURL: URL? = Bundle.main.executableURL, @@ -48,4 +89,26 @@ extension KeychainCacheStore { } return nil } + + static func hasCodeSigningCertificate(at bundleURL: URL) -> Bool { + #if os(macOS) + var staticCode: SecStaticCode? + guard SecStaticCodeCreateWithPath(bundleURL as CFURL, SecCSFlags(), &staticCode) == errSecSuccess, + let staticCode + else { return false } + + var information: CFDictionary? + guard SecCodeCopySigningInformation( + staticCode, + SecCSFlags(rawValue: kSecCSSigningInformation), + &information) == errSecSuccess, + let values = information as? [String: Any], + let certificates = values[kSecCodeInfoCertificates as String] as? [SecCertificate] + else { return false } + return !certificates.isEmpty + #else + _ = bundleURL + return false + #endif + } } diff --git a/Sources/CodexBarCore/KeychainCacheStore.swift b/Sources/CodexBarCore/KeychainCacheStore.swift index c01b3eb935..6d7b8e496b 100644 --- a/Sources/CodexBarCore/KeychainCacheStore.swift +++ b/Sources/CodexBarCore/KeychainCacheStore.swift @@ -513,12 +513,14 @@ public enum KeychainCacheStore { !KeychainAccessGate.isDisabled } - /// When the user disables Keychain access, keep an in-process cache so cookie/session + /// When persistent cookie-cache access is unavailable, keep an in-process cache so session /// reconciliation can still succeed without treating every refresh as a session change. /// Unit tests keep using the isolated test stores instead, unless a test explicitly opts in. private static func shouldUseDisabledAccessMemoryStore(for category: String) -> Bool { #if DEBUG - if self.disabledAccessMemoryStoreEnabledForTesting == true { + if self.disabledAccessMemoryStoreEnabledForTesting == true || + self.bundledAdHocProcessOverrideForTesting == true + { return category == "cookie" } if KeychainTestSafety.isRunningUnderTests( @@ -538,7 +540,7 @@ public enum KeychainCacheStore { return true } guard category == "cookie" else { return false } - return KeychainAccessGate.isExplicitlyDisabled + return KeychainAccessGate.isExplicitlyDisabled || self.isBundledAdHocProcess } /// True when the running executable has no `.app` bundle ancestor. @@ -587,7 +589,7 @@ public enum KeychainCacheStore { } #endif - /// Drops the in-process fallback used while Keychain access is explicitly disabled. + /// Drops the in-process fallback used when persistent cookie-cache access is unavailable. static func clearDisabledAccessMemoryStore() { self.disabledAccessMemoryLock.lock() self.disabledAccessMemoryStore.removeAll() @@ -599,7 +601,8 @@ public enum KeychainCacheStore { private static var prefersDisabledAccessMemoryStoreOverTestStore: Bool { #if DEBUG - self.disabledAccessMemoryStoreEnabledForTesting == true + self.disabledAccessMemoryStoreEnabledForTesting == true || + self.bundledAdHocProcessOverrideForTesting == true #else false #endif @@ -846,7 +849,7 @@ public enum KeychainCacheStore { defer { self.disabledAccessMemoryLock.unlock() } let memoryKey = TestStoreKey(service: self.serviceName, account: key.account) self.disabledAccessMemoryStore[memoryKey] = data - self.log.debug("Keychain cache stored in memory (Keychain access disabled)", metadata: [ + self.log.debug("Cookie cache stored in process memory", metadata: [ "account": key.account, ]) return true diff --git a/Sources/CodexBarCore/LocalAgentSessionScanner.swift b/Sources/CodexBarCore/LocalAgentSessionScanner.swift index 7bdd986877..cf41281930 100644 --- a/Sources/CodexBarCore/LocalAgentSessionScanner.swift +++ b/Sources/CodexBarCore/LocalAgentSessionScanner.swift @@ -20,9 +20,26 @@ final class FutureModificationDateClamp: @unchecked Sendable { } } +private final class TrustedCodexAppServerCache: @unchecked Sendable { + private let lock = NSLock() + private var trustedExecutablePaths = Set() + + func isTrusted(_ path: String, validator: @Sendable (String) -> Bool) -> Bool { + self.lock.withLock { + if self.trustedExecutablePaths.contains(path) { + return true + } + guard validator(path) else { return false } + self.trustedExecutablePaths.insert(path) + return true + } + } +} + public struct LocalAgentSessionScanner: Sendable { typealias ProcessOutputProvider = @Sendable ([String: String]) async -> String typealias CWDProvider = @Sendable ([Int32], [String: String]) async -> [Int32: String] + typealias AppServerTrustValidator = @Sendable (String) -> Bool private struct Rollout: Sendable { let url: URL @@ -36,20 +53,24 @@ public struct LocalAgentSessionScanner: Sendable { let now: Date let codexAppServerPresent: Bool let includeFileOnlySessions: Bool + let includeTrustedCodexAppServerRollouts: Bool let threadMetadata: [String: CodexThreadMetadata] let piFamilySessions: [AgentSession] } public let config: SessionScanConfig private let futureModificationDateClamp = FutureModificationDateClamp() + private let trustedCodexAppServerCache = TrustedCodexAppServerCache() private let processOutputProvider: ProcessOutputProvider? private let cwdProvider: CWDProvider? + private let appServerTrustValidator: AppServerTrustValidator private let didVisitDirectoryEntry: (@Sendable () -> Void)? public init(config: SessionScanConfig = SessionScanConfig()) { self.config = config self.processOutputProvider = nil self.cwdProvider = nil + self.appServerTrustValidator = { CodexLaunchPreflight.isLaunchCandidateAllowed(path: $0) } self.didVisitDirectoryEntry = nil } @@ -57,11 +78,15 @@ public struct LocalAgentSessionScanner: Sendable { config: SessionScanConfig = SessionScanConfig(), processOutputProvider: @escaping ProcessOutputProvider, cwdProvider: @escaping CWDProvider, + appServerTrustValidator: @escaping AppServerTrustValidator = { + CodexLaunchPreflight.isLaunchCandidateAllowed(path: $0) + }, didVisitDirectoryEntry: (@Sendable () -> Void)? = nil) { self.config = config self.processOutputProvider = processOutputProvider self.cwdProvider = cwdProvider + self.appServerTrustValidator = appServerTrustValidator self.didVisitDirectoryEntry = didVisitDirectoryEntry } @@ -79,11 +104,22 @@ public struct LocalAgentSessionScanner: Sendable { let processes = Array(AgentSessionCorrelation.newestProcessesFirst( AgentPSOutputParser.agentProcesses(from: allProcesses)) .prefix(max(0, self.config.maxProcessCount))) + let homeDirectory = URL(fileURLWithPath: environment["HOME"] ?? NSHomeDirectory(), isDirectory: true) + let trustedCodexAppServerPresent = if let executable = AgentPSOutputParser.chatGPTCodexAppServerExecutable( + in: allProcesses, + homeDirectory: homeDirectory) + { + self.trustedCodexAppServerCache.isTrusted(executable, validator: self.appServerTrustValidator) + } else { + false + } guard Self.shouldScanSessionMetadata( hasAgentProcesses: !processes.isEmpty, - includeFileOnlySessions: includeFileOnlySessions) + includeFileOnlySessions: includeFileOnlySessions, + hasTrustedCodexAppServer: trustedCodexAppServerPresent) else { return [] } - let codexAppServerPresent = AgentPSOutputParser.hasCodexAppServer(in: allProcesses) + let codexAppServerPresent = AgentPSOutputParser.hasCodexAppServer(in: allProcesses) || + trustedCodexAppServerPresent let cwdByPID = if let cwdProvider = self.cwdProvider { await cwdProvider(processes.map(\ .pid), environment) } else { @@ -93,7 +129,6 @@ public struct LocalAgentSessionScanner: Sendable { guard AgentPSOutputParser.provider(for: process) == .codex else { return nil } return cwdByPID[process.pid] } - let homeDirectory = URL(fileURLWithPath: environment["HOME"] ?? NSHomeDirectory(), isDirectory: true) let codexHomeDirectory = URL( fileURLWithPath: environment["CODEX_HOME"] ?? homeDirectory.appendingPathComponent(".codex").path, isDirectory: true) @@ -121,11 +156,14 @@ public struct LocalAgentSessionScanner: Sendable { host: host, config: self.config), directoryBudget: &piFamilyDirectoryBudget) - let rollouts: [Rollout] = if includeFileOnlySessions || !codexCWDs.isEmpty { + let includeTrustedCodexAppServerRollouts = trustedCodexAppServerPresent && !includeFileOnlySessions + let rollouts: [Rollout] = if includeFileOnlySessions || !codexCWDs.isEmpty || + includeTrustedCodexAppServerRollouts + { self.codexRollouts( now: now, codexHomeDirectory: codexHomeDirectory, - matchingCWDs: includeFileOnlySessions ? nil : codexCWDs, + matchingCWDs: includeFileOnlySessions || includeTrustedCodexAppServerRollouts ? nil : codexCWDs, directoryBudget: &directoryBudget) } else { [] @@ -144,6 +182,7 @@ public struct LocalAgentSessionScanner: Sendable { now: now, codexAppServerPresent: codexAppServerPresent, includeFileOnlySessions: includeFileOnlySessions, + includeTrustedCodexAppServerRollouts: includeTrustedCodexAppServerRollouts, threadMetadata: threadMetadata, piFamilySessions: piFamilySessions), directoryBudget: &directoryBudget) @@ -151,9 +190,10 @@ public struct LocalAgentSessionScanner: Sendable { public static func shouldScanSessionMetadata( hasAgentProcesses: Bool, - includeFileOnlySessions: Bool) -> Bool + includeFileOnlySessions: Bool, + hasTrustedCodexAppServer: Bool = false) -> Bool { - hasAgentProcesses || includeFileOnlySessions + hasAgentProcesses || includeFileOnlySessions || hasTrustedCodexAppServer } private static func codexThreadMetadata( @@ -278,7 +318,8 @@ public struct LocalAgentSessionScanner: Sendable { } for rollout in rollouts - where context.includeFileOnlySessions && !matchedRolloutPaths.contains(rollout.url.path) + where (context.includeFileOnlySessions || context.includeTrustedCodexAppServerRollouts) && + !matchedRolloutPaths.contains(rollout.url.path) { guard var session = CodexRolloutFirstLineParser.makeSession( metadata: rollout.metadata, diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityCLISession.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityCLISession.swift index 47526c8871..4acdbd5f9c 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityCLISession.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityCLISession.swift @@ -31,21 +31,25 @@ protocol AntigravityCLIProcessLaunching: Sendable { enum AntigravityCLIAuthenticationPrompt { static let evidence = Data("Select login method:".utf8) - private static let promptPattern = #"select\s+login\s+method\s*:?"# + private static let authenticationPatterns = [ + #"select\s+login\s+method\s*:?"#, + #"you\s+are\s+not\s+logged\s+into\s+antigravity"#, + #"keyring\s*auth\s*:\s*timed\s+out\b"#, + ] static func contains(_ output: Data) -> Bool { // `agy` briefly prints "You are currently not signed in" before it - // auto-refreshes an existing login. The actual blocking state is the - // interactive login-method prompt. The exact prompt is a CLI-owned TUI - // string, so keep matching tolerant to casing and whitespace changes. + // auto-refreshes an existing login. Only a blocking login prompt, an + // explicit Antigravity logout, or exhausted keyring authentication + // establishes that the managed session cannot recover its credentials. if output.range(of: self.evidence) != nil { return true } let asciiBytes = output.map { $0 < 0x80 ? $0 : 0x20 } let text = String(bytes: asciiBytes, encoding: .utf8) ?? "" - return text.range( - of: self.promptPattern, - options: [.regularExpression, .caseInsensitive]) != nil + return self.authenticationPatterns.contains { pattern in + text.range(of: pattern, options: [.regularExpression, .caseInsensitive]) != nil + } } } @@ -1087,12 +1091,16 @@ final class AntigravitySpawnedPTYProcessHandle: AntigravityCLIProcessHandle, @un retries = 0 continue } - if written == 0 { break } + if written == 0 { + break + } let err = errno if err == EINTR || err == EAGAIN || err == EWOULDBLOCK { retries += 1 - if retries > 200 { return } + if retries > 200 { + return + } usleep(5000) continue } diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityLocalReader.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityLocalReader.swift new file mode 100644 index 0000000000..57f1fdb896 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityLocalReader.swift @@ -0,0 +1,208 @@ +import Foundation + +// MARK: - Antigravity Local Reader (tokscale compatible) + +enum AntigravityLocalReader { + static func tokiCachePaths(home: URL? = nil) -> [URL] { + let base: URL = if let home { + home.appendingPathComponent(".config/tokscale/antigravity-cache/sessions", isDirectory: true) + } else if let dir = ProcessInfo.processInfo.environment["TOKSCALE_CONFIG_DIR"] { + URL(fileURLWithPath: dir).appendingPathComponent("antigravity-cache/sessions", isDirectory: true) + } else { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".config/tokscale/antigravity-cache/sessions", isDirectory: true) + } + guard let contents = try? FileManager.default.contentsOfDirectory( + at: base, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) else { return [] } + return contents.filter { $0.pathExtension == "jsonl" }.sorted { $0.lastPathComponent < $1.lastPathComponent } + } + + static func cliDBPaths(home: URL? = nil) -> [URL] { + let base: URL = if let env = ProcessInfo.processInfo.environment["GEMINI_CLI_HOME"], !env.isEmpty { + URL(fileURLWithPath: env).appendingPathComponent("antigravity-cli/conversations", isDirectory: true) + } else if let home { + home.appendingPathComponent(".gemini/antigravity-cli/conversations", isDirectory: true) + } else { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".gemini/antigravity-cli/conversations", isDirectory: true) + } + guard let c = try? FileManager.default.contentsOfDirectory( + at: base, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) else { return [] } + return c + .filter { + $0.pathExtension == "db" && !$0.lastPathComponent.hasSuffix("-wal") && !$0.lastPathComponent + .hasSuffix("-shm") + } + .sorted { $0.lastPathComponent < $1.lastPathComponent } + } + + static func parseJSONLCache(paths: [URL]? = nil, calendar: Calendar = .current) -> [CostUsageDailyReport.Entry] { + var entries: [CostUsageDailyReport.Entry] = [] + var seenResponseIds = Set() + for url in paths ?? self.tokiCachePaths() { + guard let data = try? Data(contentsOf: url), + let text = String(data: data, encoding: .utf8) else { continue } + var sessionModel: String? + for line in text.components(separatedBy: .newlines) { + let t = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !t.isEmpty, let d = t.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: d) as? [String: Any] else { continue } + let type = json["type"] as? String + if type == "session_meta" { + sessionModel = json["modelId"] as? String ?? json["model_id"] as? String + continue + } + if type == "usage" || json["input"] != nil { + if let rid = (json["responseId"] as? String ?? json["response_id"] as? String), !rid.isEmpty { + if seenResponseIds.contains(rid) { continue } + seenResponseIds.insert(rid) + } + let modelId = + (json["modelId"] as? String ?? json["model_id"] as? String ?? sessionModel ?? "unknown") + let input = (json["input"] as? Int) ?? 0 + let output = (json["output"] as? Int) ?? 0 + let read = (json["cacheRead"] as? Int) ?? (json["cache_read"] as? Int) ?? 0 + let write = (json["cacheWrite"] as? Int) ?? (json["cache_write"] as? Int) ?? 0 + let reason = (json["reasoning"] as? Int) ?? 0 + let ts = (json["timestamp"] as? Int64) ?? (json["timestamp"] as? Int).map(Int64.init) ?? 0 + let date = self.timestampToDayKey(ts, calendar: calendar) + let total = input + output + read + write + if total == 0 { continue } + if let idx = entries.firstIndex(where: { $0.date == date }) { + var e = entries[idx] + let ne = CostUsageDailyReport.Entry( + date: e.date, + inputTokens: (e.inputTokens ?? 0) + input, + outputTokens: (e.outputTokens ?? 0) + output, + cacheReadTokens: (e.cacheReadTokens ?? 0) + read, + cacheCreationTokens: (e.cacheCreationTokens ?? 0) + write, + reasoningTokens: (e.reasoningTokens ?? 0) + reason, + totalTokens: (e.totalTokens ?? 0) + total, + requestCount: (e.requestCount ?? 0) + 1, + costUSD: e.costUSD, + modelsUsed: nil, + modelBreakdowns: self.mergeBreakdown(e.modelBreakdowns, model: modelId, tokens: total)) + entries[idx] = ne + } else { + let e = CostUsageDailyReport.Entry( + date: date, + inputTokens: input, + outputTokens: output, + cacheReadTokens: read, + cacheCreationTokens: write, + reasoningTokens: reason, + totalTokens: total, + requestCount: 1, + costUSD: nil, + modelsUsed: nil, + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown( + modelName: modelId, + costUSD: nil, + totalTokens: total, + requestCount: 1), + ]) + entries.append(e) + } + } + } + } + return entries.sorted { $0.date < $1.date } + } + + static func parseCLIDBs() -> [CostUsageDailyReport.Entry] { + [] + } + + static func makeDailyReport(calendar: Calendar = .current) -> CostUsageDailyReport { + var merged: [String: CostUsageDailyReport.Entry] = [:] + for e in self.parseJSONLCache(calendar: calendar) + self.parseCLIDBs() { + if var ex = merged[e.date] { + let mergedCost: Double? = { + if ex.costUSD == nil, e.costUSD == nil { return nil } + return (ex.costUSD ?? 0) + (e.costUSD ?? 0) + }() + let ne = CostUsageDailyReport.Entry( + date: ex.date, + inputTokens: (ex.inputTokens ?? 0) + (e.inputTokens ?? 0), + outputTokens: (ex.outputTokens ?? 0) + (e.outputTokens ?? 0), + cacheReadTokens: (ex.cacheReadTokens ?? 0) + (e.cacheReadTokens ?? 0), + cacheCreationTokens: (ex.cacheCreationTokens ?? 0) + (e.cacheCreationTokens ?? 0), + reasoningTokens: (ex.reasoningTokens ?? 0) + (e.reasoningTokens ?? 0), + totalTokens: (ex.totalTokens ?? 0) + (e.totalTokens ?? 0), + requestCount: (ex.requestCount ?? 0) + (e.requestCount ?? 0), + costUSD: mergedCost, + modelsUsed: nil, + modelBreakdowns: self.mergeBreakdowns(ex.modelBreakdowns, e.modelBreakdowns)) + merged[e.date] = ne + } else { + merged[e.date] = e + } + } + let sorted = merged.values.sorted { $0.date < $1.date } + let costValues = sorted.compactMap(\.costUSD) + let totalCost: Double? = costValues.isEmpty ? nil : costValues.reduce(0, +) + let totalTokens = sorted.compactMap(\.totalTokens).reduce(0, +) + let summary: CostUsageDailyReport.Summary? = sorted.isEmpty ? nil : .init( + totalInputTokens: nil, + totalOutputTokens: nil, + totalTokens: totalTokens, + totalCostUSD: totalCost) + return CostUsageDailyReport(data: sorted, summary: summary) + } + + private static func timestampToDayKey(_ ms: Int64, calendar: Calendar = .current) -> String { + let sec = Double(ms) / 1000.0 + let date = Date(timeIntervalSince1970: sec) + let c = calendar + let comps = c.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", comps.year ?? 1970, comps.month ?? 1, comps.day ?? 1) + } + + private static func mergeBreakdown( + _ ex: [CostUsageDailyReport.ModelBreakdown]?, + model: String, + tokens: Int) -> [CostUsageDailyReport.ModelBreakdown] + { + var arr = ex ?? [] + if let i = arr.firstIndex(where: { $0.modelName == model }) { + let b = arr[i] + arr[i] = CostUsageDailyReport.ModelBreakdown( + modelName: b.modelName, + costUSD: b.costUSD, + totalTokens: (b.totalTokens ?? 0) + tokens, + requestCount: (b.requestCount ?? 0) + 1) + } else { + arr.append(CostUsageDailyReport.ModelBreakdown( + modelName: model, + costUSD: nil, + totalTokens: tokens, + requestCount: 1)) + } + return arr + } + + private static func mergeBreakdowns( + _ a: [CostUsageDailyReport.ModelBreakdown]?, + _ b: [CostUsageDailyReport.ModelBreakdown]?) -> [CostUsageDailyReport.ModelBreakdown]? + { + var d: [String: CostUsageDailyReport.ModelBreakdown] = [:] + for m in (a ?? []) + (b ?? []) { + if var ex = d[m.modelName] { + let mergedCost: Double? = { + if ex.costUSD == nil, m.costUSD == nil { return nil } + return (ex.costUSD ?? 0) + (m.costUSD ?? 0) + }() + ex = CostUsageDailyReport.ModelBreakdown( + modelName: ex.modelName, + costUSD: mergedCost, + totalTokens: (ex.totalTokens ?? 0) + (m.totalTokens ?? 0), + requestCount: (ex.requestCount ?? 0) + (m.requestCount ?? 0)) + d[m.modelName] = ex + } else { + d[m.modelName] = m + } + } + return d.isEmpty ? nil : Array(d.values) + } +} diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityOfflineStore.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityOfflineStore.swift index 993574d2c3..16fc7beaa5 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityOfflineStore.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityOfflineStore.swift @@ -23,6 +23,11 @@ public enum AntigravityOfflineStore { .appendingPathComponent("conversations", isDirectory: true) } + public static func appDataDirectory(home: URL, env: [String: String] = [:]) -> URL { + self.geminiHomeDirectory(home: home, env: env) + .appendingPathComponent("antigravity", isDirectory: true) + } + /// Tokscale cache alternative: `~/.config/tokscale/antigravity-cache/sessions` public static func tokscaleCacheDirectory(home: URL) -> URL { home.appendingPathComponent(".config", isDirectory: true) @@ -38,8 +43,13 @@ public enum AntigravityOfflineStore { fileManager: FileManager = .default) -> Int { let primary = self.conversationsDirectory(home: home, env: env) + let appData = self.appDataDirectory(home: home, env: env) + let appDataConversations = appData.appendingPathComponent("conversations", isDirectory: true) let primaryCount = self.countDBFiles(in: primary, fileManager: fileManager) - if primaryCount > 0 { return primaryCount } + let appDataCount = self.countDBFiles(in: appData, fileManager: fileManager) + let appDataConvCount = self.countDBFiles(in: appDataConversations, fileManager: fileManager) + let totalDB = primaryCount + appDataCount + appDataConvCount + if totalDB > 0 { return totalDB } // Fallback to tokscale JSONL cache (also counts as offline availability) let cache = self.tokscaleCacheDirectory(home: home) return self.countJSONLFiles(in: cache, fileManager: fileManager) diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift index c7401d9dbf..467a15ccd2 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift @@ -49,8 +49,9 @@ public enum AntigravityProviderDescriptor { ProviderColor(hex: 0xFBBC04), ]), tokenCost: ProviderTokenCostConfig( - supportsTokenCost: false, - noDataMessage: { "Antigravity cost summary is not supported." }), + supportsTokenCost: true, + noDataMessage: { "Antigravity cost summary is not supported." }, + supportsTokenSnapshot: true), pace: ProviderPaceCapability( sessionPaceWindowRule: .custom { window, _ in window.windowMinutes == nil || window.windowMinutes == 300 @@ -75,7 +76,9 @@ public enum AntigravityProviderDescriptor { }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .cli, .oauth], - pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), + pipeline: ProviderFetchPipeline( + resolveStrategies: self.resolveStrategies, + resolveFallbackError: self.resolveFallbackError)), cli: ProviderCLIConfig( name: "antigravity", versionDetector: nil)) @@ -204,6 +207,15 @@ public enum AntigravityProviderDescriptor { let fileURL = AntigravityOAuthCredentialsStore.defaultURL(home: homeURL) return FileManager.default.fileExists(atPath: fileURL.path) } + + static func resolveFallbackError(_ previous: Error?, _ current: Error) -> Error { + if (previous as? AntigravityStatusProbeError) == .authenticationRequired, + (current as? AntigravityStatusProbeError) == .notRunning + { + return previous ?? current + } + return current + } } struct AntigravityStatusFetchStrategy: ProviderFetchStrategy { @@ -552,10 +564,10 @@ struct AntigravityCLIHTTPSFetchStrategy: ProviderFetchStrategy { // CodexBar must not manage its lifecycle, idle timeout, or // `resetAfterFetch` teardown. Those apply only to processes CodexBar // itself spawns on the fallback path below. - // Long-lived hosts already keep a managed session warm. Restrict external - // process reuse to one-shot CLI calls so app/server lifecycle accounting - // stays entirely inside AntigravityCLISession. - if resetAfterFetch, let warmSnapshot = try await Self.tryWarmAgyFetch( + // Persistent hosts also need the external path: a user-owned, signed-in + // `agy` may have credentials a newly spawned managed session cannot read. + // Owned pids remain excluded, so their lifecycle accounting is unchanged. + if let warmSnapshot = try await Self.tryWarmAgyFetch( timeout: 2.0, expectedBinaryPath: binary, expectedAccountEmail: expectedAccountEmail, @@ -782,8 +794,11 @@ struct AntigravityOAuthFetchStrategy: ProviderFetchStrategy { sourceLabel: "oauth") } - func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { - false + func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool { + let homeURL = context.env["HOME"] + .flatMap { $0.isEmpty ? nil : URL(fileURLWithPath: $0, isDirectory: true) } + ?? FileManager.default.homeDirectoryForCurrentUser + return AntigravityOfflineStore.hasOfflineData(home: homeURL, env: context.env) } } @@ -830,7 +845,7 @@ struct AntigravityOfflineFetchStrategy: ProviderFetchStrategy { updatedAt: Date(), identity: ProviderIdentitySnapshot( providerID: .antigravity, - accountEmail: AntigravitySelectedAccountGuard.selectedAccountEmail(context: context), + accountEmail: nil, accountOrganization: nil, loginMethod: "offline")) return self.makeResult(usage: snapshot, sourceLabel: "offline") diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index 3142e7cffe..cb1052d2ac 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -1,7 +1,16 @@ import Foundation +import SweetCookieKit public enum ClaudeProviderDescriptor { public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + private static var browserCookieOrder: BrowserCookieImportOrder? { + #if os(macOS) + [.chrome] + Browser.defaultImportOrder.filter { $0 != .chrome } + #else + nil + #endif + } + private static let ttyLaunch = ProviderTTYLaunchConfig( executableOverrideEnvironmentKey: "CLAUDE_CLI_PATH", bundledWatchdogHelperName: "CodexBarClaudeWatchdog", @@ -123,7 +132,7 @@ public enum ClaudeProviderDescriptor { probeLogOrder: 1, notificationSimulationOrder: 1, errorSimulationOrder: 1), - browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder, + browserCookieOrder: self.browserCookieOrder, dashboardURL: "https://console.anthropic.com/settings/billing", subscriptionDashboardURL: "https://claude.ai/settings/usage", changelogURL: "https://github.com/anthropics/claude-code/releases", @@ -180,6 +189,7 @@ public enum ClaudeProviderDescriptor { menuCardStyle: .claude) }, iconDecorations: [.notches], + reservesMissingSecondaryIconLane: true, automaticSelectionPrioritizesExhaustedWindow: false, menuBarWindowResolver: self.menuBarWindow, planUtilizationSeriesResolver: { snapshot in diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift index 6f7a47d86f..f3793edba6 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift @@ -37,6 +37,8 @@ enum ClaudeWebPrepaidCreditsRequest { enum ClaudeWebSessionKeyImport { #if DEBUG @TaskLocal static var overrideForTesting: ClaudeWebAPIFetcher.SessionKeyInfo? + @TaskLocal static var browserOverrideForTesting: + (@Sendable (Browser) throws -> ClaudeWebAPIFetcher.SessionKeyInfo?)? #endif static var currentOverride: ClaudeWebAPIFetcher.SessionKeyInfo? { @@ -46,6 +48,16 @@ enum ClaudeWebSessionKeyImport { nil #endif } + + static var currentBrowserOverride: + (@Sendable (Browser) throws -> ClaudeWebAPIFetcher.SessionKeyInfo?)? + { + #if DEBUG + self.browserOverrideForTesting + #else + nil + #endif + } } private actor ClaudeWebBrowserFetchGate { @@ -535,33 +547,42 @@ extension ClaudeWebAPIFetcher { let cookieDomains = ["claude.ai"] - // Filter to cookie-eligible browsers to avoid unnecessary keychain prompts - let installedBrowsers = Self.cookieImportOrder.cookieImportCandidates(using: browserDetection) - for browserSource in installedBrowsers { - do { - let query = BrowserCookieQuery(domains: cookieDomains) - let sources = try Self.cookieClient.codexBarRecords( - matching: query, - in: browserSource, - logger: log) - for source in sources { - if let sessionKey = findSessionKey(in: source.records.map { record in - (name: record.name, value: record.value) - }) { - log("Found sessionKey in \(source.label)") - return SessionKeyInfo( - key: sessionKey, - sourceLabel: source.label, - cookieCount: source.records.count) + return try KeychainAccessPreflight.withMemoizedGenericPasswordChecks { + // Evaluate sources on demand so a successful preferred browser avoids later Keychain preflights. + let installedBrowsers = Self.cookieImportOrder.lazyCookieImportCandidates(using: browserDetection) + for browserSource in installedBrowsers { + do { + if let override = ClaudeWebSessionKeyImport.currentBrowserOverride { + if let sessionInfo = try override(browserSource) { + log("Found sessionKey in \(sessionInfo.sourceLabel)") + return sessionInfo + } + continue } + let query = BrowserCookieQuery(domains: cookieDomains) + let sources = try Self.cookieClient.codexBarRecords( + matching: query, + in: browserSource, + logger: log) + for source in sources { + if let sessionKey = findSessionKey(in: source.records.map { record in + (name: record.name, value: record.value) + }) { + log("Found sessionKey in \(source.label)") + return SessionKeyInfo( + key: sessionKey, + sourceLabel: source.label, + cookieCount: source.records.count) + } + } + } catch { + BrowserCookieAccessGate.recordIfNeeded(error) + log("\(browserSource.displayName) cookie load failed: \(error.localizedDescription)") } - } catch { - BrowserCookieAccessGate.recordIfNeeded(error) - log("\(browserSource.displayName) cookie load failed: \(error.localizedDescription)") } - } - throw FetchError.noSessionKeyFound + throw FetchError.noSessionKeyFound + } } private static func findSessionKey(in cookies: [(name: String, value: String)]) -> String? { diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorLocalCSVReader.swift b/Sources/CodexBarCore/Providers/Cursor/CursorLocalCSVReader.swift new file mode 100644 index 0000000000..c7b73ddb0d --- /dev/null +++ b/Sources/CodexBarCore/Providers/Cursor/CursorLocalCSVReader.swift @@ -0,0 +1,204 @@ +import Foundation + +// MARK: - Cursor Local CSV Reader (tokscale compatible) + +enum CursorLocalCSVReader { + static func cachedCSVPaths(home: URL? = nil) -> [URL] { + let base: URL = if let home { + home.appendingPathComponent(".config/tokscale/cursor-cache", isDirectory: true) + } else if let dir = ProcessInfo.processInfo.environment["TOKSCALE_CONFIG_DIR"] { + URL(fileURLWithPath: dir).appendingPathComponent("cursor-cache", isDirectory: true) + } else { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".config/tokscale/cursor-cache", isDirectory: true) + } + guard let contents = try? FileManager.default.contentsOfDirectory( + at: base, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) + else { return [] } + return contents.filter { + let n = $0.lastPathComponent + return n.hasPrefix("usage") && n.hasSuffix(".csv") && !$0.path.contains("/archive/") && !n + .hasPrefix("usage.backup") + }.sorted { $0.lastPathComponent < $1.lastPathComponent } + } + + struct Row: Sendable { + let date: Date + let model: String + let input: Int + let cacheRead: Int + let cacheWrite: Int + let output: Int + let totalTokens: Int? + let cost: Double + } + + static func parseFile(at url: URL, calendar: Calendar = .current) -> [Row] { + guard let data = try? Data(contentsOf: url), + let text = String(data: data, encoding: .utf8) else { return [] } + let lines = text.components(separatedBy: .newlines).filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + guard let header = lines.first else { return [] } + let cols = self.parseCSVLine(header) + let hasKind = cols.contains { $0.lowercased() == "kind" } + struct Idx { let model: Int; let inW: Int; let inWo: Int; let read: Int; let out: Int; let cost: Int } + let idx = if hasKind, cols.count >= 11 { + Idx(model: 4, inW: 6, inWo: 7, read: 8, out: 9, cost: 11) + } else if hasKind { + Idx(model: 2, inW: 4, inWo: 5, read: 6, out: 7, cost: 9) + } else { + Idx(model: 1, inW: 2, inWo: 3, read: 4, out: 5, cost: 7) + } + var rows: [Row] = [] + for line in lines.dropFirst() { + let c = self.parseCSVLine(line) + guard c.count > max(idx.model, idx.cost) else { continue } + let model = c[idx.model].trimmingCharacters(in: .whitespacesAndNewlines) + guard !model.isEmpty else { continue } + let inW = self.parseInt(c[idx.inW]) + let inWo = self.parseInt(c[idx.inWo]) + let read = self.parseInt(c[idx.read]) + let out = self.parseInt(c[idx.out]) + let cost = self.parseCost(c[idx.cost]) + // Parse the authoritative Total Tokens column if present. + let totalIdx = idx.cost - 1 + let totalTokens = totalIdx < cols.count && totalIdx < c.count + && cols[totalIdx].lowercased().contains("total") ? self.parseInt(c[totalIdx]) : nil + guard let date = parseDate(c[0], calendar: calendar) else { continue } + let write = max(0, inW - inWo) + let input = inWo + if input == 0, out == 0, read == 0, write == 0 { continue } + rows.append(Row( + date: date, + model: model, + input: input, + cacheRead: read, + cacheWrite: write, + output: out, + totalTokens: totalTokens, + cost: cost)) + } + return rows + } + + static func makeDailyReport( + from rows: [Row], + calendar: Calendar = .current, + now: Date = Date()) -> CostUsageDailyReport + { + var byDay: [String: CostUsageDailyReport.Entry] = [:] + var totalCost: Double = 0 + var totalTokens = 0 + for row in rows { + let key = CostUsageLocalDay.key(from: row.date, calendar: calendar) + let existing = byDay[key] + var e = existing ?? CostUsageDailyReport.Entry( + date: key, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalTokens: 0, + requestCount: 0, + costUSD: 0, + modelsUsed: nil, + modelBreakdowns: []) + // Honor the CSV's authoritative Total Tokens column when present; + // otherwise derive from component columns. + let tot = row.totalTokens ?? (row.input + row.output + row.cacheRead + row.cacheWrite) + var bds = e.modelBreakdowns ?? [] + if let i = bds.firstIndex(where: { $0.modelName == row.model }) { + let b = bds[i] + bds[i] = CostUsageDailyReport.ModelBreakdown( + modelName: b.modelName, + costUSD: (b.costUSD ?? 0) + row.cost, + totalTokens: (b.totalTokens ?? 0) + tot, + requestCount: (b.requestCount ?? 0) + 1) + } else { + bds.append(CostUsageDailyReport.ModelBreakdown( + modelName: row.model, + costUSD: row.cost, + totalTokens: tot, + requestCount: 1)) + } + let ne = CostUsageDailyReport.Entry( + date: key, + inputTokens: (e.inputTokens ?? 0) + row.input, + outputTokens: (e.outputTokens ?? 0) + row.output, + cacheReadTokens: (e.cacheReadTokens ?? 0) + row.cacheRead, + cacheCreationTokens: (e.cacheCreationTokens ?? 0) + row.cacheWrite, + totalTokens: (e.totalTokens ?? 0) + tot, + requestCount: (e.requestCount ?? 0) + 1, + costUSD: (e.costUSD ?? 0) + row.cost, + modelsUsed: e.modelsUsed, + modelBreakdowns: bds) + byDay[key] = ne + totalCost += row.cost + totalTokens += tot + } + let sorted = byDay.values.sorted { $0.date < $1.date } + let summary: CostUsageDailyReport.Summary? = sorted.isEmpty ? nil : .init( + totalInputTokens: nil, + totalOutputTokens: nil, + totalTokens: totalTokens, + totalCostUSD: totalCost) + return CostUsageDailyReport(data: sorted, summary: summary) + } + + static func parseCSVLine(_ line: String) -> [String] { + var cols: [String] = [] + var cur = "" + var q = false + for ch in line { + if ch == "\"" { q.toggle() } else if ch == ",", !q { + cols.append(cur); cur = "" + } else { cur.append(ch) } + } + cols.append(cur) + return cols + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "\"\"", with: "\"") } + } + + static func parseInt(_ s: String) -> Int { + Int(s.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: ",", with: "")) ?? 0 + } + + static func parseCost(_ s: String) -> Double { + let t = s.trimmingCharacters(in: .whitespacesAndNewlines) + if t.isEmpty || t == "-" || t.lowercased() == "included" || t.lowercased() == "nan" { return 0 } + return Double(t.replacingOccurrences(of: "$", with: "").replacingOccurrences(of: ",", with: "")) ?? 0 + } + + static func parseDate(_ s: String, calendar: Calendar) -> Date? { + let t = s.trimmingCharacters(in: .whitespacesAndNewlines) + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let d = iso.date(from: t) { return d } + iso.formatOptions = [.withInternetDateTime] + if let d = iso.date(from: t) { return d } + let df = DateFormatter() + df.locale = Locale(identifier: "en_US_POSIX") + df.timeZone = TimeZone(secondsFromGMT: 0) + for fmt in ["yyyy-MM-dd'T'HH:mm:ss.SSS", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd"] { + df.dateFormat = fmt + if let d = df.date(from: t) { + if fmt == "yyyy-MM-dd" { + // Keep date-only rows in the configured calendar's day (noon in that calendar), + // so UTC+13/14 users don't see the row shifted to the next local day. + var utcCal = Calendar(identifier: .gregorian) + utcCal.timeZone = TimeZone(secondsFromGMT: 0)! + let utcComps = utcCal.dateComponents([.year, .month, .day], from: d) + var targetComps = DateComponents() + targetComps.year = utcComps.year + targetComps.month = utcComps.month + targetComps.day = utcComps.day + targetComps.hour = 12 + targetComps.minute = 0 + targetComps.second = 0 + return calendar.date(from: targetComps) ?? d + } + return d + } + } + return nil + } +} diff --git a/Sources/CodexBarCore/Providers/Grok/GrokCreditsProxyFetcher.swift b/Sources/CodexBarCore/Providers/Grok/GrokCreditsProxyFetcher.swift index 74ce605f5a..25c5204778 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokCreditsProxyFetcher.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokCreditsProxyFetcher.swift @@ -80,7 +80,7 @@ public enum GrokCreditsProxyFetcher { if resetsAt != nil { return GrokWebBillingSnapshot( - usedPercent: 0, + usedPercent: nil, resetsAt: resetsAt, subscriptionTier: subscriptionTier) } diff --git a/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift b/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift index 0f564194d7..4df482fb2c 100644 --- a/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift +++ b/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift @@ -306,19 +306,23 @@ extension ProviderFetchStrategy { public struct ProviderFetchPipeline: Sendable { public typealias RetrySleeper = @Sendable (TimeInterval) async throws -> Void + public typealias FallbackErrorResolver = @Sendable (Error?, Error) -> Error public let resolveStrategies: @Sendable (ProviderFetchContext) async -> [any ProviderFetchStrategy] private let retrySleeper: RetrySleeper + private let resolveFallbackError: FallbackErrorResolver public init( resolveStrategies: @escaping @Sendable (ProviderFetchContext) async -> [any ProviderFetchStrategy], retrySleeper: @escaping RetrySleeper = { seconds in guard seconds > 0 else { return } try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - }) + }, + resolveFallbackError: @escaping FallbackErrorResolver = { _, error in error }) { self.resolveStrategies = resolveStrategies self.retrySleeper = retrySleeper + self.resolveFallbackError = resolveFallbackError } public func fetch(context: ProviderFetchContext, provider: UsageProvider) async -> ProviderFetchOutcome { @@ -361,7 +365,7 @@ public struct ProviderFetchPipeline: Sendable { errorDescription: nil)) return ProviderFetchOutcome(result: .success(result), attempts: attempts) } catch { - lastAvailableError = error + lastAvailableError = self.resolveFallbackError(lastAvailableError, error) attempts.append(ProviderFetchAttempt( strategyID: strategy.id, kind: strategy.kind, diff --git a/Sources/CodexBarCore/Providers/ProviderUsagePresentation.swift b/Sources/CodexBarCore/Providers/ProviderUsagePresentation.swift index fba4bdc0e1..4121c101d7 100644 --- a/Sources/CodexBarCore/Providers/ProviderUsagePresentation.swift +++ b/Sources/CodexBarCore/Providers/ProviderUsagePresentation.swift @@ -423,6 +423,7 @@ public struct ProviderUsagePresentation: Sendable { private let widgetRowLimitResolver: WidgetRowLimitResolver public let iconDecorations: ProviderIconDecorations public let treatsExhaustedSecondaryIconWindowAsMissing: Bool + public let reservesMissingSecondaryIconLane: Bool public let primarySemanticWindow: ProviderSemanticWindow public let secondarySemanticWindow: ProviderSemanticWindow public let menuBarLayoutSecondaryLabel: String? @@ -448,6 +449,7 @@ public struct ProviderUsagePresentation: Sendable { }, iconDecorations: ProviderIconDecorations = [], treatsExhaustedSecondaryIconWindowAsMissing: Bool = false, + reservesMissingSecondaryIconLane: Bool = false, semanticWindowResolver: @escaping SemanticWindowResolver = Self.standardSemanticWindows, primarySemanticWindow: ProviderSemanticWindow = .session, secondarySemanticWindow: ProviderSemanticWindow = .weekly, @@ -473,6 +475,7 @@ public struct ProviderUsagePresentation: Sendable { self.iconWindowResolver = iconWindowResolver self.iconDecorations = iconDecorations self.treatsExhaustedSecondaryIconWindowAsMissing = treatsExhaustedSecondaryIconWindowAsMissing + self.reservesMissingSecondaryIconLane = reservesMissingSecondaryIconLane self.semanticWindowResolver = semanticWindowResolver self.primarySemanticWindow = primarySemanticWindow self.secondarySemanticWindow = secondarySemanticWindow diff --git a/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudProviderDescriptor.swift b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudProviderDescriptor.swift index 368085344c..900bdcb237 100644 --- a/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudProviderDescriptor.swift @@ -12,7 +12,19 @@ public enum QwenCloudProviderDescriptor { static func makeDescriptor() -> ProviderDescriptor { #if os(macOS) - let browserOrder: BrowserCookieImportOrder = [.chrome] + // AGENTS.md L48: "Cookie imports: default Chrome-only when possible to + // avoid other browser prompts; override via browser list when needed." + // The override here is the minimum necessary: Chrome + Brave. The full + // 7-browser list (chromeBeta, edge, arc, firefox, safari) is + // deliberately omitted so automatic refreshes do not surface unwanted + // Keychain / browser-store access prompts on browsers that don't carry + // a Qwen Cloud session. Brave is kept because it shares the same + // Chromium Safe Storage format as Chrome and is a common Qwen Cloud + // authentication target. + let browserOrder: BrowserCookieImportOrder = [ + .chrome, + .brave, + ] #else let browserOrder: BrowserCookieImportOrder? = nil #endif @@ -88,7 +100,11 @@ struct QwenCloudWebFetchStrategy: ProviderFetchStrategy { private static let log = CodexBarLog.logger("qwen-cloud") #if os(macOS) - static let browserOrder: BrowserCookieImportOrder = [.chrome] + /// Mirrors the descriptor's browserOrder above. Keep them in sync. + static let browserOrder: BrowserCookieImportOrder = [ + .chrome, + .brave, + ] #endif let id: String = "qwen-cloud.web" diff --git a/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudSettingsReader.swift b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudSettingsReader.swift index 3acc78a145..30f9635aa7 100644 --- a/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudSettingsReader.swift @@ -53,8 +53,15 @@ public enum QwenCloudSettingsError: LocalizedError, Sendable { public var errorDescription: String? { switch self { case let .missingCookie(details): + // Qwen Cloud cookie import now probes Chrome *and* Brave (per the + // QwenCloudProviderDescriptor.browserOrder override). The recovery + // message must name both supported browsers and their respective + // Safe Storage entries, otherwise a Brave-only user would be told + // to sign in with Chrome and the fix would be invisible. let base = "No Qwen Cloud session cookies found in browsers. " + - "Sign in to Qwen Cloud in Chrome, allow CodexBar to access Chrome Safe Storage in Keychain Access, " + + "Sign in to Qwen Cloud in Chrome or Brave, " + + "allow CodexBar to access the corresponding Safe Storage in Keychain Access " + + "(Chrome Safe Storage and/or Brave Safe Storage), " + "or paste a manual Cookie header." guard let details, !details.isEmpty else { return base } return "\(base) \(details)" diff --git a/Sources/CodexBarCore/Resources/Plugins/openrouter.js b/Sources/CodexBarCore/Resources/Plugins/openrouter.js index 5e2a0f83f4..e28b05eed9 100644 --- a/Sources/CodexBarCore/Resources/Plugins/openrouter.js +++ b/Sources/CodexBarCore/Resources/Plugins/openrouter.js @@ -112,26 +112,27 @@ defineProvider({ } else try { const now = ctx.date.now(); - const today = now.toISOString().slice(0, 10); - const cutoffDate = new Date(now.getTime() - 29 * 24 * 60 * 60 * 1000); + const latestCompletedDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); + const latestCompleted = latestCompletedDate.toISOString().slice(0, 10); + const cutoffDate = new Date(latestCompletedDate.getTime() - 29 * 24 * 60 * 60 * 1000); const cutoff = cutoffDate.toISOString().slice(0, 10); // A management credential must never follow the user-configurable API base to a proxy. const activityURL = "https://openrouter.ai/api/v1/activity"; - const [historyResponse, todayResponse] = await Promise.all([ + const [historyResponse, latestCompletedResponse] = await Promise.all([ ctx.http.get(activityURL, { timeoutSeconds: optionalRequestTimeoutSeconds, openRouterManagementAuth: true, }), - ctx.http.get(`${activityURL}?date=${encodeURIComponent(today)}`, { + ctx.http.get(`${activityURL}?date=${encodeURIComponent(latestCompleted)}`, { timeoutSeconds: optionalRequestTimeoutSeconds, openRouterManagementAuth: true, }), ]); - if (historyResponse.status !== 200 || todayResponse.status !== 200) { - const failed = historyResponse.status !== 200 ? historyResponse : todayResponse; + if (historyResponse.status !== 200 || latestCompletedResponse.status !== 200) { + const failed = historyResponse.status !== 200 ? historyResponse : latestCompletedResponse; activityDegradation = activityDegradationReason(failed.status); } else { - const payloads = [historyResponse, todayResponse].map((response) => JSON.parse(response.bodyText)); + const payloads = [historyResponse, latestCompletedResponse].map((response) => JSON.parse(response.bodyText)); const rows = payloads.flatMap((payload) => { if (!payload || !Array.isArray(payload.data)) throw new TypeError("activity.data must be an array"); return payload.data; @@ -157,7 +158,9 @@ defineProvider({ if (!Number.isFinite(parsedDate.getTime()) || parsedDate.toISOString().slice(0, 10) !== date) { throw new TypeError(`activity.data[${index}].date must be a real calendar date`); } - if (date > today) throw new TypeError(`activity.data[${index}].date must not be in the future`); + if (date > latestCompleted) { + throw new TypeError(`activity.data[${index}].date must be a completed UTC day`); + } if (date < cutoff) continue; const rawModel = row.model_permaslug ?? row.model; const model = typeof rawModel === "string" && rawModel.trim() ? rawModel.trim() : null; @@ -246,7 +249,7 @@ defineProvider({ currency: "USD", historyDays: 30, historyLabel: "Last 30 days (UTC)", - windowEnd: today, + windowEnd: latestCompleted, entries, }; } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+ReportReconciliation.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+ReportReconciliation.swift index 8614b0019d..a1b42686a0 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+ReportReconciliation.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+ReportReconciliation.swift @@ -57,6 +57,16 @@ extension CostUsageScanner { rows: reconciledRows, range: range, priorityTurns: priorityTurns) + // The persisted maps are derived from the rows' own `pricingMode`, while the report also + // consults the trace database. A turn the trace reports as priority after its rows were + // persisted as standard is a tier-classification difference, not a row-ownership problem, + // so retention is judged against both splits and only a group that matches neither counts + // as mismatched. A wrongly retained row set still fails both, because the persisted totals + // are canonical for the file and neither classification changes how many tokens the rows carry. + let retainedModeTokens = self.codexModeTokenMaps( + rows: reconciledRows, + range: range, + priorityTurns: [:]) var mismatchGroups = Set() var priorityGroups = Set() for (day, models) in usage.days { @@ -76,11 +86,13 @@ extension CostUsageScanner { // Copied fork prefixes can stale these legacy maps too. Only a map that is // independently canonical for its file may constrain retained row ownership. guard persistedModeTotal == canonicalTotal else { continue } - let rowStandard = reconciledModeTokens.standard?[day]?[model] ?? 0 - let rowPriority = reconciledModeTokens.priority?[day]?[model] ?? 0 - if rowStandard != max(0, persistedStandard ?? 0) - || rowPriority != max(0, persistedPriority ?? 0) - { + let expectedStandard = max(0, persistedStandard ?? 0) + let expectedPriority = max(0, persistedPriority ?? 0) + let matchesReconciled = (reconciledModeTokens.standard?[day]?[model] ?? 0) == expectedStandard + && (reconciledModeTokens.priority?[day]?[model] ?? 0) == expectedPriority + let matchesRetained = (retainedModeTokens.standard?[day]?[model] ?? 0) == expectedStandard + && (retainedModeTokens.priority?[day]?[model] ?? 0) == expectedPriority + if !matchesReconciled, !matchesRetained { mismatchGroups.insert(key) } } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 8fffbdf1e4..68febeb25f 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -412,6 +412,33 @@ enum CostUsageScanner { lhs.input <= rhs.input && lhs.cached <= rhs.cached && lhs.output <= rhs.output } + private static func codexLooksLikeStaleRegression( + current: CostUsageCodexTotals, + previous: CostUsageCodexTotals, + last: CostUsageCodexTotals) -> Bool + { + // Mirrors tokscale: staleness applies only after an actual field-level + // regression, including the optional reasoning subset. Compare reasoning + // only when both snapshots provide it; an omitted field is unknown, not zero. + let reasoningRegressed: Bool = switch (current.reasoning, previous.reasoning) { + case let (.some(currentReasoning), .some(previousReasoning)): + currentReasoning < previousReasoning + case (.some, .none), (.none, .some), (.none, .none): + false + } + guard current.input < previous.input + || current.cached < previous.cached + || current.output < previous.output + || reasoningRegressed + else { return false } + let previousTotal = previous.input + previous.output + previous.cached + (previous.reasoning ?? 0) + let currentTotal = current.input + current.output + current.cached + (current.reasoning ?? 0) + let lastTotal = last.input + last.output + last.cached + (last.reasoning ?? 0) + if previousTotal <= 0 || currentTotal <= 0 || lastTotal <= 0 { return false } + return currentTotal * 100 >= previousTotal * 98 + || currentTotal + lastTotal * 2 >= previousTotal + } + private static func codexShouldPreferTotalDelta( rawBaseline: CostUsageCodexTotals?, currentTotal: CostUsageCodexTotals, @@ -703,6 +730,17 @@ enum CostUsageScanner { if self.tracker.isSeen(total) { return base } + let staleBaseline = self.tracker.watermark ?? self.rawTotalsBaseline + if let previousTotal = staleBaseline, + CostUsageScanner.codexLooksLikeStaleRegression( + current: total, + previous: previousTotal, + last: last ?? .init(input: 0, cached: 0, output: 0)) + { + // Mirrors tokscale: a cumulative snapshot that regressed by roughly + // one recent increment is stale, not a second lineage or hard reset. + return base + } self.tracker.latchIfBelowWatermark(total) } let watermarkBaseline = self.tracker.watermark ?? self.rawTotalsBaseline @@ -3418,15 +3456,17 @@ enum CostUsageScanner { let input = max( 0, Self.extractJSONByteIntField(Self.codexJSONFieldInputTokens, from: bytes, in: objectRange, atDepth: 1) ?? 0) - let cached = max( - 0, - Self.extractJSONByteIntField(Self.codexJSONFieldCachedInputTokens, from: bytes, in: objectRange, atDepth: 1) - ?? Self.extractJSONByteIntField( - Self.codexJSONFieldCacheReadInputTokens, - from: bytes, - in: objectRange, - atDepth: 1) - ?? 0) + let cachedInput = Self.extractJSONByteIntField( + Self.codexJSONFieldCachedInputTokens, + from: bytes, + in: objectRange, + atDepth: 1) ?? 0 + let cacheRead = Self.extractJSONByteIntField( + Self.codexJSONFieldCacheReadInputTokens, + from: bytes, + in: objectRange, + atDepth: 1) ?? 0 + let cached = max(0, max(cachedInput, cacheRead)) let output = max( 0, Self @@ -3665,6 +3705,54 @@ enum CostUsageScanner { } } + /// Extracts usage from non-event rollout lines (one-shot codex exec / headless output). + /// Only the four canonical response envelopes are inspected so arbitrary prompt text cannot + /// be misread as token data. + private static func codexBareUsage( + from obj: [String: Any]) -> (totals: CostUsageCodexTotals, model: String?)? + { + let containers = [ + obj["usage"] as? [String: Any], + (obj["data"] as? [String: Any]).flatMap { $0["usage"] as? [String: Any] }, + (obj["result"] as? [String: Any]).flatMap { $0["usage"] as? [String: Any] }, + (obj["response"] as? [String: Any]).flatMap { $0["usage"] as? [String: Any] }, + ] + + guard let usage = containers.compactMap(\.self).first, + let inputTokens = Self.codexBareUsageInt( + usage, keys: ["input_tokens", "prompt_tokens", "input"]), + let outputTokens = Self.codexBareUsageInt( + usage, keys: ["output_tokens", "completion_tokens", "output"]) + else { return nil } + + let cachedTokens = Self.codexBareUsageInt( + usage, keys: ["cached_input_tokens", "cache_read_input_tokens", "cached_tokens"]) ?? 0 + let billedInput = max(0, inputTokens - cachedTokens) + guard billedInput > 0 || outputTokens > 0 || cachedTokens > 0 else { return nil } + + func modelEvidence(_ container: [String: Any]?) -> String? { + Self.codexModelEvidence(container?["model"] as? String) + ?? Self.codexModelEvidence(container?["model_name"] as? String) + } + + return ( + CostUsageCodexTotals( + input: billedInput, + cached: cachedTokens, + output: outputTokens, + reasoning: nil), + modelEvidence(obj) ?? (obj["data"] as? [String: Any]).flatMap(modelEvidence)) + } + + private static func codexBareUsageInt(_ dict: [String: Any], keys: [String]) -> Int? { + for key in keys { + if let number = dict[key] as? NSNumber { + return max(0, number.intValue) + } + } + return nil + } + private static func codexFastLineTimestampValidity(_ bytes: Data) -> Bool? { let timestamp = bytes.withUnsafeBytes { rawBytes in let rawBuffer = rawBytes.bindMemory(to: UInt8.self) @@ -3909,7 +3997,9 @@ enum CostUsageScanner { let output = toInt($0["output_tokens"]) return CostUsageCodexTotals( input: toInt($0["input_tokens"]), - cached: toInt($0["cached_input_tokens"] ?? $0["cache_read_input_tokens"]), + cached: max( + toInt($0["cached_input_tokens"] ?? 0), + toInt($0["cache_read_input_tokens"] ?? 0)), output: output, reasoning: ($0["reasoning_output_tokens"] as? NSNumber) .map { min(max(0, $0.intValue), max(0, output)) }) @@ -3918,7 +4008,11 @@ enum CostUsageScanner { let output = max(0, toInt($0["output_tokens"])) return CostUsageCodexTotals( input: max(0, toInt($0["input_tokens"])), - cached: max(0, toInt($0["cached_input_tokens"] ?? $0["cache_read_input_tokens"])), + cached: max( + 0, + max( + toInt($0["cached_input_tokens"] ?? 0), + toInt($0["cache_read_input_tokens"] ?? 0))), output: output, reasoning: ($0["reasoning_output_tokens"] as? NSNumber) .map { min(max(0, $0.intValue), output) }) @@ -4055,6 +4149,7 @@ enum CostUsageScanner { var days: [String: [String: [Int]]] = [:] var rows: [CodexUsageRow] = [] var tokenSnapshots: [CostUsageCodexTokenSnapshot] = [] + var lastAcceptedTokenTimestamp: String? func add(dayKey: String, model: String, input: Int, cached: Int, output: Int) { guard CostUsageDayRange.isInRange(dayKey: dayKey, since: range.scanSinceKey, until: range.scanUntilKey) @@ -4083,6 +4178,43 @@ enum CostUsageScanner { return Int64((date.timeIntervalSince1970 * 1000).rounded()) } + /// Counts one-shot codex exec / headless rollout rows whose usage object is not wrapped in the + /// interactive event_msg/token_count envelope. Tokscale parity: accept OpenAI and completion-style + /// aliases, subtract cached input from billed input, and fall back to the last accepted timestamp + /// so timestamp-less responses remain attributable to the active day. + func handleBareUsage(totals: CostUsageCodexTotals, modelEvidence: String?, timestamp: String?) { + let resolvedTimestamp = timestamp ?? lastAcceptedTokenTimestamp + guard let dayKey = resolvedTimestamp.flatMap({ + Self.dayKeyFromTimestamp($0) ?? Self.dayKeyFromParsedISO($0) + }) else { return } + + observeTimestamp(resolvedTimestamp) + let model = Self.codexModelEvidence(modelEvidence) + ?? Self.codexModelEvidence(currentModel) + ?? CostUsagePricing.codexUnattributedModel + let normModel = CostUsagePricing.normalizeCodexModel(model) + + let eventIndex = codexUsageRowIndex + codexUsageRowIndex += 1 + add(dayKey: dayKey, model: normModel, input: totals.input, cached: totals.cached, output: totals.output) + if CostUsageDayRange.isInRange(dayKey: dayKey, since: range.scanSinceKey, until: range.scanUntilKey) { + rows.append(CodexUsageRow( + day: dayKey, + model: normModel, + rawModel: model, + turnID: currentTurnID, + eventIndex: eventIndex, + timestampUnixMs: unixMilliseconds(from: resolvedTimestamp), + input: totals.input, + cached: totals.cached, + output: totals.output, + reasoning: totals.reasoning)) + } + if let resolvedTimestamp { + lastAcceptedTokenTimestamp = resolvedTimestamp + } + } + func observeTimestamp(_ timestamp: String?) { guard let unixMs = unixMilliseconds(from: timestamp) else { return } codexSession.startedAtUnixMs = switch codexSession.startedAtUnixMs { @@ -4240,6 +4372,19 @@ enum CostUsageScanner { if tracker.isSeen(adjustedTotal) { return } + let staleBaseline = tracker.watermark ?? rawTotalsBaseline + if let previousTotal = staleBaseline, + !hasUnresolvedForkBaseline, + Self.codexLooksLikeStaleRegression( + current: adjustedTotal, + previous: previousTotal, + last: last ?? .init(input: 0, cached: 0, output: 0)) + { + // Keep the cancellable parser aligned with the snapshot accumulator: + // stale regressions are skipped before they can reset the baseline or + // latch interleaved mode. + return + } tracker.latchIfBelowWatermark(adjustedTotal) } let watermarkBaseline = tracker.watermark ?? rawTotalsBaseline @@ -4532,6 +4677,20 @@ enum CostUsageScanner { return } + if line.bytes.containsAscii(#""usage""#) { + autoreleasepool { + guard let obj = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any], + obj["type"] == nil, + let bare = Self.codexBareUsage(from: obj) + else { return } + handleBareUsage( + totals: bare.totals, + modelEvidence: bare.model, + timestamp: obj["timestamp"] as? String) + } + return + } + guard line.bytes.containsAscii(#""type":"event_msg""#) || line.bytes.containsAscii(#""type":"turn_context""#) @@ -4675,7 +4834,9 @@ enum CostUsageScanner { let output = max(0, toInt(usage["output_tokens"])) return CostUsageCodexTotals( input: max(0, toInt(usage["input_tokens"])), - cached: max(0, toInt(usage["cached_input_tokens"] ?? usage["cache_read_input_tokens"])), + cached: max( + max(0, toInt(usage["cached_input_tokens"])), + max(0, toInt(usage["cache_read_input_tokens"]))), output: output, reasoning: (usage["reasoning_output_tokens"] as? NSNumber) .map { min(max(0, $0.intValue), output) }) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift index e64ba4a105..f639df7e72 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift @@ -82,6 +82,8 @@ actor CostUsageStore { "b975eb705f905b9a", // 0.49.0-0.49.2 SQLite producer with compatible rows. "47144baa8daccf52", // This branch changes only scan scheduling, discovery, and persistence bookkeeping. "2d17f4981b78d07f", // Persisted priority-turn cursor; parser and persisted row shape unchanged. + "3c984b655688593f", // 0.54.x row-ownership evidence fix; parser and persisted row shape unchanged. + "5f8507161b23757c", // 0.54.2 tokscale parity + priority evidence; persisted row shape unchanged. ] /// Test-only crash injection: invoked inside `saveCodexCache`'s transaction after each diff --git a/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift index 6915086291..eb807591bf 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift @@ -1,4 +1,9 @@ import Foundation +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif #if canImport(FoundationNetworking) import FoundationNetworking #endif @@ -427,14 +432,71 @@ enum ModelsDevCache { static let ttlSeconds: TimeInterval = 24 * 60 * 60 private static let memo = ModelsDevCacheMemo() + /// Test-only instrumentation: counts `fileMetadata(at:)` reads (one per `load`) so tests can prove callers + /// resolve the catalog once instead of per pricing call. Task-local, so concurrent tests do not see each other's + /// counts, and unset (zero cost) in production. + @TaskLocal private static var metadataReadRecorder: MetadataReadRecorder? + + final class MetadataReadRecorder: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + func record() { + self.lock.lock() + self.count += 1 + self.lock.unlock() + } + + func snapshot() -> Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.count + } + } + + static func withMetadataReadRecorderForTesting( + _ recorder: MetadataReadRecorder, + operation: () throws -> T) rethrows -> T + { + try self.$metadataReadRecorder.withValue(recorder) { + try operation() + } + } + static func withMetadataReadRecorderForTesting( + _ recorder: MetadataReadRecorder, + operation: () async throws -> T) async rethrows -> T + { + try await self.$metadataReadRecorder.withValue(recorder) { + try await operation() + } + } + + /// Cheap POSIX stat for the (mtime, size) memo key. `attributesOfItem` also reads xattrs. + /// `stat(2)` follows a terminal symlink (matching what `Data(contentsOf:)` later reads) whereas + /// `attributesOfItem` did not. private static func fileMetadata(at url: URL) -> (modificationDate: Date?, size: Int?) { - guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) else { - return (nil, nil) + self.metadataReadRecorder?.record() + + return url.withUnsafeFileSystemRepresentation { pointer in + guard let pointer else { return (nil, nil) } + var status = stat() + guard stat(pointer, &status) == 0 else { + return (nil, nil) + } + return (Self.modificationDate(from: status), Int(status.st_size)) } - let modificationDate = attributes[.modificationDate] as? Date - let size = (attributes[.size] as? NSNumber)?.intValue - return (modificationDate, size) + } + + private static func modificationDate(from status: stat) -> Date { + #if canImport(Darwin) + let seconds = TimeInterval(status.st_mtimespec.tv_sec) + let nanoseconds = TimeInterval(status.st_mtimespec.tv_nsec) + #else + let seconds = TimeInterval(status.st_mtim.tv_sec) + let nanoseconds = TimeInterval(status.st_mtim.tv_nsec) + #endif + return Date(timeIntervalSince1970: seconds + nanoseconds / 1_000_000_000) } private static func defaultCacheRoot() -> URL { diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift index 05ab5645c0..1e7d6eb60b 100644 --- a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift @@ -54,12 +54,21 @@ enum OpenCodexUsageAggregator { var sawCost = false } + /// Aggregates OpenCodex usage entries into a per-window token/cost snapshot. + /// + /// Pricing context is resolved once per call and shared by every entry: `modelsDevCatalog` is the models.dev + /// catalog and `customPricingOverlay` the app-level custom-pricing overlay file. Callers that aggregate several + /// providers (see `OpenCodexUsageFanOut`) resolve both once and pass them in; when either is nil it is resolved + /// here once. Each windowed entry is priced exactly once and that price feeds the day, session, hour and model + /// accumulators, so output is identical to pricing inside each merge — without a catalog/overlay lookup per call. static func snapshot( entries: [OpenCodexUsageEntry], now: Date, historyDays: Int, calendar: Calendar, - customPricing: CostUsageCustomPricing = .empty) -> CostUsageTokenSnapshot + customPricing: CostUsageCustomPricing = .empty, + modelsDevCatalog: ModelsDevCatalog? = nil, + customPricingOverlay: CostUsageCustomPricing? = nil) -> CostUsageTokenSnapshot { let days = max(1, min(365, historyDays)) let today = calendar.startOfDay(for: now) @@ -76,25 +85,50 @@ enum OpenCodexUsageAggregator { return lhs.requestID < rhs.requestID } + // Resolve the pricing context once for the whole snapshot. A missing models.dev catalog becomes an EMPTY + // catalog on purpose: `codexCostUSD` treats a nil catalog as "resolve it yourself" and would fall back to + // `ModelsDevCache.load` (a stat per pricing target) for every entry, whereas an empty catalog yields the same + // nil lookups without any file access. Nothing is resolved when the window is empty. + let catalog: ModelsDevCatalog + let overlay: CostUsageCustomPricing + if windowed.isEmpty { + catalog = ModelsDevCatalog(providers: [:]) + overlay = .empty + } else { + catalog = modelsDevCatalog + ?? CostUsagePricing.modelsDevCatalog() + ?? ModelsDevCatalog(providers: [:]) + overlay = customPricingOverlay ?? CostUsagePricing.customPricingOverlay() + } + var daysByKey: [String: DayAccumulator] = [:] var sessions: [String: SessionAccumulator] = [:] var hoursByStart: [Date: HourAccumulator] = [:] + // `windowed` is sorted by timestamp, so the day/hour memos hit on almost every entry; a miss only costs one + // Calendar interval lookup. Price once per entry and reuse it for the day, session and hour merges. + var dayMemo = LocalDayKeyMemo() + var hourMemo = HourStartMemo() for entry in windowed { - let dayKey = CostUsageLocalDay.key(from: entry.timestamp, calendar: calendar) + let cost = Self.listPriceUSD( + entry: entry, + customPricing: customPricing, + modelsDevCatalog: catalog, + customPricingOverlay: overlay) + let dayKey = dayMemo.key(for: entry.timestamp, calendar: calendar) var day = daysByKey[dayKey] ?? DayAccumulator() - Self.merge(entry, into: &day, customPricing: customPricing) + Self.merge(entry, cost: cost, into: &day) daysByKey[dayKey] = day let sessionID = entry.conversationID ?? entry.requestID var session = sessions[sessionID] ?? SessionAccumulator() session.lastActivity = max(session.lastActivity, entry.timestamp) session.requests += 1 - Self.merge(entry, into: &session, customPricing: customPricing) + Self.merge(entry, cost: cost, into: &session) sessions[sessionID] = session - let hour = calendar.dateInterval(of: .hour, for: entry.timestamp)?.start ?? entry.timestamp + let hour = hourMemo.start(for: entry.timestamp, calendar: calendar) var hourBucket = hoursByStart[hour] ?? HourAccumulator() - Self.merge(entry, into: &hourBucket, customPricing: customPricing) + Self.merge(entry, cost: cost, into: &hourBucket) hoursByStart[hour] = hourBucket } @@ -164,8 +198,8 @@ enum OpenCodexUsageAggregator { private static func merge( _ entry: OpenCodexUsageEntry, - into day: inout DayAccumulator, - customPricing: CostUsageCustomPricing) + cost: Double?, + into day: inout DayAccumulator) { let usage = entry.usage if let input = usage?.inputTokens { @@ -197,7 +231,6 @@ enum OpenCodexUsageAggregator { day.unmetered += entry.usageStatus == .unsupported ? 1 : 0 day.unpriced += entry.usageStatus == .unreported ? 1 : 0 - let cost = Self.listPriceUSD(entry: entry, customPricing: customPricing) if let cost { day.cost += cost day.sawCost = true @@ -220,15 +253,14 @@ enum OpenCodexUsageAggregator { private static func merge( _ entry: OpenCodexUsageEntry, - into session: inout SessionAccumulator, - customPricing: CostUsageCustomPricing) + cost: Double?, + into session: inout SessionAccumulator) { session.input = self.add(session.input, entry.usage?.inputTokens) session.output = self.add(session.output, entry.usage?.outputTokens) session.cacheRead = self.add(session.cacheRead, entry.usage?.cacheReadTokens) session.reasoning = self.add(session.reasoning, entry.usage?.reasoningOutputTokens) session.tokens = self.add(session.tokens, entry.resolvedTotalTokens) - let cost = self.listPriceUSD(entry: entry, customPricing: customPricing) session.cost = self.add(session.cost, cost) var model = session.models[entry.model] ?? ModelAccumulator() self.merge(entry, cost: cost, into: &model) @@ -237,14 +269,14 @@ enum OpenCodexUsageAggregator { private static func merge( _ entry: OpenCodexUsageEntry, - into hour: inout HourAccumulator, - customPricing: CostUsageCustomPricing) + cost: Double?, + into hour: inout HourAccumulator) { if let tokens = entry.resolvedTotalTokens { hour.tokens += tokens hour.sawTokens = true } - if let cost = self.listPriceUSD(entry: entry, customPricing: customPricing) { + if let cost { hour.cost += cost hour.sawCost = true } @@ -303,9 +335,16 @@ enum OpenCodexUsageAggregator { } } + /// List-price estimate for one entry. Precedence is unchanged from the per-merge pricing it replaces: + /// 1. `customPricing` — the snapshot's own overlay (provider-scoped rates passed by the caller); + /// 2. `CostUsagePricing.codexCostUSD` with the pre-resolved `customPricingOverlay` (the app-level overlay file, + /// which `codexCostUSD` would otherwise re-load per call) and the pre-resolved models.dev `modelsDevCatalog` + /// (otherwise `ModelsDevCache.load` per call), then the bundled/historical tables. private static func listPriceUSD( entry: OpenCodexUsageEntry, - customPricing: CostUsageCustomPricing) -> Double? + customPricing: CostUsageCustomPricing, + modelsDevCatalog: ModelsDevCatalog, + customPricingOverlay: CostUsageCustomPricing) -> Double? { guard entry.usageStatus == .reported || entry.usageStatus == .estimated else { return nil } let usage = entry.usage @@ -335,7 +374,9 @@ enum OpenCodexUsageAggregator { cachedInputTokens: cacheRead, outputTokens: output, cacheWriteInputTokens: cacheWrite, - pricingDate: entry.timestamp) + pricingDate: entry.timestamp, + modelsDevCatalog: modelsDevCatalog, + customPricing: customPricingOverlay) } private static func add(_ lhs: Int?, _ rhs: Int?) -> Int? { @@ -356,3 +397,51 @@ enum OpenCodexUsageAggregator { } } } + +extension OpenCodexUsageAggregator { + /// Reuses the calendar's `[start, next)` day interval while timestamps stay inside it. + /// Day keys still come from `CostUsageLocalDay` so DST and non-Gregorian calendars stay aligned: the key derives + /// y-m-d from the same Gregorian-in-timezone calendar whose `.day` interval is cached here, so the memo can never + /// disagree with computing the key per entry (DST days are simply 23 h / 25 h intervals). + private struct LocalDayKeyMemo { + var start = Date.distantPast + var end = Date.distantPast + var key = "" + + mutating func key(for timestamp: Date, calendar: Calendar) -> String { + if timestamp >= self.start, timestamp < self.end { + return self.key + } + let dayCalendar = CostUsageLocalDay.gregorianCalendar(matching: calendar) + guard let interval = dayCalendar.dateInterval(of: .day, for: timestamp) else { + self.start = Date.distantPast + self.end = Date.distantPast + return CostUsageLocalDay.key(from: timestamp, calendar: calendar) + } + self.start = interval.start + self.end = interval.end + self.key = CostUsageLocalDay.key(from: timestamp, calendar: calendar) + return self.key + } + } + + /// Reuses the calendar's hour interval while timestamps stay inside `[start, end)`. + private struct HourStartMemo { + var start = Date.distantPast + var end = Date.distantPast + + mutating func start(for timestamp: Date, calendar: Calendar) -> Date { + if timestamp >= self.start, timestamp < self.end { + return self.start + } + guard let interval = calendar.dateInterval(of: .hour, for: timestamp) else { + self.start = Date.distantPast + self.end = Date.distantPast + return timestamp + } + self.start = interval.start + self.end = interval.end + return self.start + } + } +} diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageFanOut.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageFanOut.swift index 8bec20f312..a060ff8df6 100644 --- a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageFanOut.swift +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageFanOut.swift @@ -18,13 +18,21 @@ public enum OpenCodexUsageFanOut { } grouped[provider, default: []].append(entry) } + guard !grouped.isEmpty else { return [:] } + // Resolve the models.dev catalog and the custom-pricing overlay once for all providers; each snapshot then + // prices its entries against this shared context instead of re-reading both per pricing call (see + // `OpenCodexUsageAggregator.snapshot`). A missing catalog is passed as an empty one for the same reason. + let catalog = CostUsagePricing.modelsDevCatalog() ?? ModelsDevCatalog(providers: [:]) + let overlay = CostUsagePricing.customPricingOverlay() return grouped.mapValues { providerEntries in OpenCodexUsageAggregator.snapshot( entries: providerEntries, now: now, historyDays: historyDays, calendar: calendar, - customPricing: customPricing) + customPricing: customPricing, + modelsDevCatalog: catalog, + customPricingOverlay: overlay) } } diff --git a/Tests/CodexBarTests/AgentSessionMenuDescriptorTests.swift b/Tests/CodexBarTests/AgentSessionMenuDescriptorTests.swift index e971acf994..7f89d9ec2c 100644 --- a/Tests/CodexBarTests/AgentSessionMenuDescriptorTests.swift +++ b/Tests/CodexBarTests/AgentSessionMenuDescriptorTests.swift @@ -114,7 +114,7 @@ struct AgentSessionMenuDescriptorTests { } @Test - func `adaptive-only metadata reads require a detected agent process`() { + func `adaptive-only metadata reads require an agent or trusted codex app server`() { #expect(!LocalAgentSessionScanner.shouldScanSessionMetadata( hasAgentProcesses: false, includeFileOnlySessions: false)) @@ -124,6 +124,10 @@ struct AgentSessionMenuDescriptorTests { #expect(LocalAgentSessionScanner.shouldScanSessionMetadata( hasAgentProcesses: false, includeFileOnlySessions: true)) + #expect(LocalAgentSessionScanner.shouldScanSessionMetadata( + hasAgentProcesses: false, + includeFileOnlySessions: false, + hasTrustedCodexAppServer: true)) } @Test diff --git a/Tests/CodexBarTests/AgentSessionParserTests.swift b/Tests/CodexBarTests/AgentSessionParserTests.swift index 5c0d0d83ee..842347f6e5 100644 --- a/Tests/CodexBarTests/AgentSessionParserTests.swift +++ b/Tests/CodexBarTests/AgentSessionParserTests.swift @@ -35,6 +35,19 @@ struct AgentSessionParserTests { } } + @Test + func `chatgpt bundled codex app server is recognized without becoming a live agent`() { + let records = AgentPSOutputParser.parse(""" + 4234 1 Mon Jul 6 09:03:00 2026 /Applications/ChatGPT.app/Contents/Resources/codex \ + -c features.code_mode_host=true app-server --analytics-default-enabled + 20409 1 Mon Jul 6 09:04:00 2026 /Applications/ChatGPT.app/Contents/Resources/codex-code-mode-host + """) + + #expect(records.count == 2) + #expect(AgentPSOutputParser.agentProcesses(from: records).isEmpty) + #expect(AgentPSOutputParser.hasCodexAppServer(in: records)) + } + @Test func `pi dialects use stable Codable raw values`() throws { let provider = try JSONEncoder().encode(AgentSession.Provider.pi) diff --git a/Tests/CodexBarTests/AntigravityCLIHTTPSFetchStrategyTests.swift b/Tests/CodexBarTests/AntigravityCLIHTTPSFetchStrategyTests.swift index d5b5fb6539..2ca34e9496 100644 --- a/Tests/CodexBarTests/AntigravityCLIHTTPSFetchStrategyTests.swift +++ b/Tests/CodexBarTests/AntigravityCLIHTTPSFetchStrategyTests.swift @@ -645,6 +645,37 @@ struct AntigravityCLIHTTPSFetchStrategyTests { #expect(snapshot.accountEmail == "user@example.com") } + @Test + func `cli HTTPS stops when managed agy reports exhausted keyring authentication`() async { + let output = AntigravityCLIOutputSequence([ + Data("You are currently not signed in.\nSigning in...".utf8), + Data("keyringAuth: timed out after 10s, skipping keyring auth".utf8), + ]) + let portPolls = AntigravityCLICounter() + + do { + _ = try await AntigravityCLIHTTPSFetchStrategy.waitForSnapshot( + pid: 123, + deadline: Date().addingTimeInterval(2), + dependencies: AntigravityCLIHTTPSFetchStrategy.SnapshotWaitDependencies( + pollIntervalNanoseconds: 0, + listeningPorts: { _, _ in + portPolls.increment() + return [] + }, + drainOutput: { output.next() }, + fetchSnapshot: { _ in + Issue.record("An unauthenticated helper must not fetch a snapshot") + throw AntigravityStatusProbeError.notRunning + })) + Issue.record("Expected authentication failure") + } catch AntigravityStatusProbeError.authenticationRequired { + #expect(portPolls.value == 1) + } catch { + Issue.record("Expected authenticationRequired, got \(error)") + } + } + @Test func `cli HTTPS rechecks signed out prompt after snapshot readiness`() async { let output = AntigravityCLIOutputSequence([ @@ -952,3 +983,82 @@ struct AntigravityCLIHTTPSFetchStrategyTests { } } } + +extension AntigravityCLIHTTPSFetchStrategyTests { + @Test + func `missing IDE cannot replace actionable signed out CLI error`() async { + let pipeline = ProviderFetchPipeline( + resolveStrategies: { _ in + [ + AntigravityFallbackFixtureStrategy( + id: "antigravity.cli-https", + error: .authenticationRequired), + AntigravityFallbackFixtureStrategy( + id: "antigravity.ide-local", + error: .notRunning), + ] + }, + resolveFallbackError: AntigravityProviderDescriptor.resolveFallbackError) + + let outcome = await pipeline.fetch(context: self.makeFetchContext(), provider: .antigravity) + + #expect(outcome.attempts.map(\.strategyID) == ["antigravity.cli-https", "antigravity.ide-local"]) + do { + _ = try outcome.result.get() + Issue.record("Expected the signed-out CLI failure") + } catch { + #expect((error as? AntigravityStatusProbeError) == .authenticationRequired) + } + } + + @Test + func `signed out CLI still allows a usable IDE fallback`() async throws { + let pipeline = ProviderFetchPipeline( + resolveStrategies: { _ in + [ + AntigravityFallbackFixtureStrategy( + id: "antigravity.cli-https", + error: .authenticationRequired), + AntigravityFallbackFixtureStrategy(id: "antigravity.ide-local", error: nil), + ] + }, + resolveFallbackError: AntigravityProviderDescriptor.resolveFallbackError) + + let outcome = await pipeline.fetch(context: self.makeFetchContext(), provider: .antigravity) + + #expect(try outcome.result.get().sourceLabel == "ide") + #expect(outcome.attempts.count == 2) + } + + @Test + func `specific later fallback error replaces signed out CLI error`() { + let result = AntigravityProviderDescriptor.resolveFallbackError( + AntigravityStatusProbeError.authenticationRequired, + AntigravityStatusProbeError.apiError("OAuth credentials expired")) + + #expect((result as? AntigravityStatusProbeError) == .apiError("OAuth credentials expired")) + } +} + +private struct AntigravityFallbackFixtureStrategy: ProviderFetchStrategy { + let id: String + let error: AntigravityStatusProbeError? + let kind: ProviderFetchKind = .localProbe + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { + if let error { + throw error + } + return self.makeResult( + usage: UsageSnapshot(primary: nil, secondary: nil, updatedAt: Date()), + sourceLabel: "ide") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + true + } +} diff --git a/Tests/CodexBarTests/AntigravityCLISessionTests.swift b/Tests/CodexBarTests/AntigravityCLISessionTests.swift index 9b5ca353cb..a743d803f7 100644 --- a/Tests/CodexBarTests/AntigravityCLISessionTests.swift +++ b/Tests/CodexBarTests/AntigravityCLISessionTests.swift @@ -418,7 +418,9 @@ private final class AntigravityManualSleeper: @unchecked Sendable { func waitForSleeps(_ expectedCount: Int) async { for _ in 0..<200 { - if self.pendingSleepCount >= expectedCount { return } + if self.pendingSleepCount >= expectedCount { + return + } try? await Task.sleep(nanoseconds: 1_000_000) } Issue.record("Timed out waiting for \(expectedCount) sleep continuation(s)") @@ -703,7 +705,9 @@ struct AntigravityCLISessionTests { let lines = output .split(separator: "\n") .map(String.init) - if lines.count >= 2, output.hasSuffix("\n") { break } + if lines.count >= 2, output.hasSuffix("\n") { + break + } } Thread.sleep(forTimeInterval: 0.01) } @@ -776,6 +780,18 @@ struct AntigravityCLISessionTests { Data("You are currently not signed in".utf8))) } + @Test + func `authentication prompt matcher recognizes logged out and exhausted keyring states`() { + #expect(AntigravityCLIHTTPSFetchStrategy.containsAuthenticationPrompt( + Data("You are not logged into Antigravity.".utf8))) + #expect(AntigravityCLIHTTPSFetchStrategy.containsAuthenticationPrompt( + Data("keyringAuth: timed out after 10s, skipping keyring auth".utf8))) + #expect(AntigravityCLIHTTPSFetchStrategy.containsAuthenticationPrompt( + Data("KEYRING auth : timed out after 10s".utf8))) + #expect(!AntigravityCLIHTTPSFetchStrategy.containsAuthenticationPrompt( + Data("Welcome. You are currently not signed in.\nSigning in...".utf8))) + } + @Test func `session returns complete new output before retaining only its tail`() async throws { let fixture = self.makeFixture() @@ -1454,7 +1470,9 @@ extension AntigravityCLISessionTests { private func waitForLaunches(_ launcher: FakeAntigravityProcessLauncher, count: Int) async -> Bool { for _ in 0..<200 { - if launcher.launchedBinarySnapshot().count >= count { return true } + if launcher.launchedBinarySnapshot().count >= count { + return true + } try? await Task.sleep(nanoseconds: 1_000_000) } return false @@ -1463,7 +1481,9 @@ extension AntigravityCLISessionTests { private func waitUntilStopped(_ session: AntigravityCLISession) async { for _ in 0..<200 { let running = await session.isRunning - if !running { return } + if !running { + return + } await Task.yield() } Issue.record("Timed out waiting for Antigravity CLI session to stop") diff --git a/Tests/CodexBarTests/AntigravityOfflineFallbackProofTests.swift b/Tests/CodexBarTests/AntigravityOfflineFallbackProofTests.swift new file mode 100644 index 0000000000..27fd127590 --- /dev/null +++ b/Tests/CodexBarTests/AntigravityOfflineFallbackProofTests.swift @@ -0,0 +1,118 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +struct AntigravityOfflineFallbackProofTests { + @Test + func `counts db in app-data directory`() throws { + let tmp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tmp) } + let appData = AntigravityOfflineStore.appDataDirectory(home: tmp, env: [:]) + try FileManager.default.createDirectory(at: appData, withIntermediateDirectories: true) + FileManager.default.createFile(atPath: appData.appendingPathComponent("a.db").path, contents: Data()) + FileManager.default.createFile(atPath: appData.appendingPathComponent("b.db").path, contents: Data()) + #expect(AntigravityOfflineStore.countConversations(home: tmp) == 2) + #expect(AntigravityOfflineStore.hasOfflineData(home: tmp)) + } + + @Test + func `counts db in app-data conversations subdirectory`() throws { + let tmp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tmp) } + let appDataConv = AntigravityOfflineStore.appDataDirectory(home: tmp, env: [:]) + .appendingPathComponent("conversations", isDirectory: true) + try FileManager.default.createDirectory(at: appDataConv, withIntermediateDirectories: true) + FileManager.default.createFile(atPath: appDataConv.appendingPathComponent("c.db").path, contents: Data()) + #expect(AntigravityOfflineStore.countConversations(home: tmp) == 1) + } + + @Test + func `offline snapshot does not carry selected OAuth email`() async throws { + let tmp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tmp) } + let conv = AntigravityOfflineStore.conversationsDirectory(home: tmp, env: [:]) + try FileManager.default.createDirectory(at: conv, withIntermediateDirectories: true) + FileManager.default.createFile(atPath: conv.appendingPathComponent("x.db").path, contents: Data()) + let credentials = AntigravityOAuthCredentials( + accessToken: "ya29.fake", + refreshToken: "1//fake", + expiryDate: Date().addingTimeInterval(3600), + email: "selected@example.com") + guard let tokenValue = try? AntigravityOAuthCredentialsStore.tokenAccountValue(for: credentials) else { + Issue.record("failed to encode credentials"); return + } + let context = Self.makeContext( + env: ["HOME": tmp.path, AntigravityOAuthCredentialsStore.environmentCredentialsKey: tokenValue], + selectedTokenAccountID: UUID()) + let strategy = AntigravityOfflineFetchStrategy() + let result = try await strategy.fetch(context) + #expect(result.usage.identity?.accountEmail == nil) + #expect(result.usage.extraRateWindows?.first?.title == "Offline · 1 conversation") + #expect(result.sourceLabel == "offline") + } + + @Test + func `oauth shouldFallback when offline data exists`() async { + let tmp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try? FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tmp) } + let conv = AntigravityOfflineStore.conversationsDirectory(home: tmp, env: [:]) + try? FileManager.default.createDirectory(at: conv, withIntermediateDirectories: true) + FileManager.default.createFile(atPath: conv.appendingPathComponent("a.db").path, contents: Data()) + let ctxWithData = Self.makeContext(env: ["HOME": tmp.path]) + let ctxEmpty = Self.makeContext(env: ["HOME": "/tmp/empty-\(UUID().uuidString)"]) + let oauth = AntigravityOAuthFetchStrategy() + let shouldFallbackWithData = await oauth.shouldFallback( + on: ProviderFetchError.noAvailableStrategy(.antigravity), + context: ctxWithData) + let shouldFallbackEmpty = await oauth.shouldFallback( + on: ProviderFetchError.noAvailableStrategy(.antigravity), + context: ctxEmpty) + #expect(shouldFallbackWithData == true) + #expect(shouldFallbackEmpty == false) + } + + private static func makeContext( + env: [String: String], + selectedTokenAccountID: UUID? = nil) -> ProviderFetchContext + { + var effectiveEnv = env + effectiveEnv["HOME"] = effectiveEnv["HOME"] + ?? FileManager.default.temporaryDirectory + .appendingPathComponent( + "codexbar-antigravity-empty-home-\(UUID().uuidString)", + isDirectory: true) + .path + return ProviderFetchContext( + runtime: .app, + sourceMode: .auto, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: effectiveEnv, + settings: nil, + fetcher: UsageFetcher(environment: effectiveEnv), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + selectedTokenAccountID: selectedTokenAccountID) + } + + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } +} diff --git a/Tests/CodexBarTests/AntigravityWarmAgyReuseTests.swift b/Tests/CodexBarTests/AntigravityWarmAgyReuseTests.swift index e8958c29cb..bb0314557f 100644 --- a/Tests/CodexBarTests/AntigravityWarmAgyReuseTests.swift +++ b/Tests/CodexBarTests/AntigravityWarmAgyReuseTests.swift @@ -345,7 +345,7 @@ struct AntigravityWarmAgyReuseTests { } @Test - func `long lived session skips external warm scan`() async throws { + func `long lived session reuses signed in external agy without spawning`() async throws { let spawnCallCount = AntigravityWarmLockedCounter() let strategy = AntigravityCLIHTTPSFetchStrategy() @@ -354,21 +354,43 @@ struct AntigravityWarmAgyReuseTests { idleWindow: 60, resetAfterFetch: false, warmDependencies: AntigravityCLIHTTPSFetchStrategy.WarmAgyDependencies( - processInfos: { _ in - Issue.record("long-lived hosts must use their managed session") - return [Self.cliProcessInfo(pid: 6301)] - }, - listeningPorts: { _, _ in [] }, - fetchSnapshot: { _, _ in throw AntigravityStatusProbeError.notRunning }), - spawnFetch: { _, _, resetAfterFetch in + processInfos: { _ in [Self.cliProcessInfo(pid: 6301)] }, + listeningPorts: { _, _ in [50080] }, + fetchSnapshot: { _, _ in Self.usableSnapshot(email: "terminal@example.com") }), + spawnFetch: { _, _, _ in + spawnCallCount.increment() + Issue.record("persistent hosts must reuse an authenticated user-owned agy") + throw AntigravityStatusProbeError.notRunning + }) + + #expect(result.usage.identity?.accountEmail == "terminal@example.com") + #expect(spawnCallCount.value == 0) + } + + @Test + func `long lived session falls back to managed agy when external account mismatches`() async throws { + let spawnCallCount = AntigravityWarmLockedCounter() + let strategy = AntigravityCLIHTTPSFetchStrategy() + + let result = try await strategy.fetchUsingWarmSession( + binary: "/usr/local/bin/agy", + idleWindow: 60, + resetAfterFetch: false, + expectedAccountEmail: "selected@example.com", + warmDependencies: AntigravityCLIHTTPSFetchStrategy.WarmAgyDependencies( + processInfos: { _ in [Self.cliProcessInfo(pid: 6301)] }, + listeningPorts: { _, _ in [50080] }, + fetchSnapshot: { _, _ in Self.usableSnapshot(email: "other@example.com") }), + spawnFetch: { _, idleWindow, resetAfterFetch in spawnCallCount.increment() + #expect(idleWindow == 60) #expect(!resetAfterFetch) return strategy.makeResult( - usage: Self.usableUsage(email: "managed@example.com"), + usage: Self.usableUsage(email: "selected@example.com"), sourceLabel: AntigravityCLIHTTPSFetchStrategy.sourceLabel) }) - #expect(result.usage.identity?.accountEmail == "managed@example.com") + #expect(result.usage.identity?.accountEmail == "selected@example.com") #expect(spawnCallCount.value == 1) } diff --git a/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift b/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift index 9081a0ac2b..1a0cc684da 100644 --- a/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift +++ b/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift @@ -16,6 +16,15 @@ struct BrowserCookieOrderStatusStringTests { #expect(Browser.defaultImportOrder.contains(.yandex)) } + @Test + func `claude cookie import prefers chrome without dropping fallback browsers`() throws { + let order = try #require(ProviderDefaults.metadata[.claude]?.browserCookieOrder) + + #expect(order.first == .chrome) + #expect(order.count == Browser.defaultImportOrder.count) + #expect(Set(order) == Set(Browser.defaultImportOrder)) + } + @Test func `cursor no session includes browser login hint`() { let order = ProviderDefaults.metadata[.cursor]?.browserCookieOrder ?? Browser.defaultImportOrder diff --git a/Tests/CodexBarTests/BrowserDetectionTests.swift b/Tests/CodexBarTests/BrowserDetectionTests.swift index 1410c1aef2..c1501e4de3 100644 --- a/Tests/CodexBarTests/BrowserDetectionTests.swift +++ b/Tests/CodexBarTests/BrowserDetectionTests.swift @@ -163,6 +163,57 @@ struct BrowserDetectionTests { #expect(browsers.cookieImportCandidates(using: detection) == [.firefox, .safari]) } + @Test + func `lazy cookie candidates stop before probing later browsers`() throws { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + + let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let chromeCookies = temp + .appendingPathComponent("Library/Application Support/Google/Chrome/Default/Network/Cookies") + try FileManager.default.createDirectory( + at: chromeCookies.deletingLastPathComponent(), + withIntermediateDirectories: true) + FileManager.default.createFile(atPath: chromeCookies.path, contents: Data()) + defer { try? FileManager.default.removeItem(at: temp) } + + let edgeProbeCount = OSAllocatedUnfairLock(initialState: 0) + let detection = BrowserDetection( + homeDirectory: temp.path, + cacheTTL: 0, + fileExists: { path in + if path == "/Applications/Google Chrome.app" { + return true + } + if path == "/Applications/Microsoft Edge.app" { + edgeProbeCount.withLock { $0 += 1 } + return true + } + return FileManager.default.fileExists(atPath: path) + }, + directoryContents: { path in + try? FileManager.default.contentsOfDirectory(atPath: path) + }) + var preflightCount = 0 + + let firstCandidate = KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in + preflightCount += 1 + return .allowed + } operation: { + ProviderInteractionContext.$current.withValue(.background) { + Array([Browser.chrome, .edge] + .lazyCookieImportCandidates(using: detection) + .prefix(1)) + } + } + } + + #expect(firstCandidate == [.chrome]) + #expect(preflightCount == 1) + #expect(edgeProbeCount.withLock { $0 } == 0) + } + @Test func `chrome requires profile data`() throws { let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) @@ -210,10 +261,6 @@ struct BrowserDetectionTests { @Test func `process filters chromium candidates despite false global keychain override`() throws { guard ProcessInfo.processInfo.environment["CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS"] != "1" else { return } - KeychainAccessGate.resetOverrideForTesting() - defer { KeychainAccessGate.resetOverrideForTesting() } - - KeychainAccessGate.isDisabled = false let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) @@ -232,7 +279,10 @@ struct BrowserDetectionTests { let detection = self.detection(homeDirectory: temp.path, installedBrowsers: [.chrome]) let browsers: [Browser] = [.chrome, .safari] - #expect(browsers.cookieImportCandidates(using: detection) == [.safari]) + let candidates = KeychainAccessGate.withStoredOverrideForTesting(false) { + browsers.cookieImportCandidates(using: detection) + } + #expect(candidates == [.safari]) } @Test diff --git a/Tests/CodexBarTests/ClaudeWebBrowserFallbackTests.swift b/Tests/CodexBarTests/ClaudeWebBrowserFallbackTests.swift new file mode 100644 index 0000000000..f664ca2505 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeWebBrowserFallbackTests.swift @@ -0,0 +1,61 @@ +import Foundation +import os.lock +import SweetCookieKit +import Testing +@testable import CodexBarCore + +#if os(macOS) +@Suite(.serialized) +struct ClaudeWebBrowserFallbackTests { + @Test + func `chrome without a Claude session falls through to Safari`() throws { + let temp = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-browser-fallback-\(UUID().uuidString)", isDirectory: true) + let chromeCookies = temp + .appendingPathComponent("Library/Application Support/Google/Chrome/Default/Network/Cookies") + try FileManager.default.createDirectory( + at: chromeCookies.deletingLastPathComponent(), + withIntermediateDirectories: true) + FileManager.default.createFile(atPath: chromeCookies.path, contents: Data()) + defer { try? FileManager.default.removeItem(at: temp) } + + let detection = BrowserDetection( + homeDirectory: temp.path, + cacheTTL: 0, + fileExists: { path in + if path == "/Applications/Google Chrome.app" { + return true + } + return FileManager.default.fileExists(atPath: path) + }, + directoryContents: { path in + try? FileManager.default.contentsOfDirectory(atPath: path) + }) + let attempts = OSAllocatedUnfairLock(initialState: [Browser]()) + let browserOverride: @Sendable (Browser) throws -> ClaudeWebAPIFetcher.SessionKeyInfo? = { browser in + attempts.withLock { $0.append(browser) } + guard browser == .safari else { return nil } + return ClaudeWebAPIFetcher.SessionKeyInfo( + key: "sk-ant-safari-fallback", + sourceLabel: "Safari", + cookieCount: 1) + } + + let sessionInfo = try BrowserCookieAccessGate.withShouldAttemptOverrideForTesting(true) { + try KeychainAccessGate.withTaskOverrideForTesting(false) { + try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in .allowed } operation: { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeWebSessionKeyImport.$browserOverrideForTesting.withValue(browserOverride) { + try ClaudeWebAPIFetcher.sessionKeyInfo(browserDetection: detection) + } + } + } + } + } + + #expect(attempts.withLock { $0 } == [.chrome, .safari]) + #expect(sessionInfo.key == "sk-ant-safari-fallback") + #expect(sessionInfo.sourceLabel == "Safari") + } +} +#endif diff --git a/Tests/CodexBarTests/CodexSessionRolloutTests.swift b/Tests/CodexBarTests/CodexSessionRolloutTests.swift index b2f9531224..d7c1f0b9c1 100644 --- a/Tests/CodexBarTests/CodexSessionRolloutTests.swift +++ b/Tests/CodexBarTests/CodexSessionRolloutTests.swift @@ -68,6 +68,98 @@ struct CodexSessionRolloutTests { #expect(!AgentSessionCorrelation.codexWorkingDirectoriesMatch("/repo/alpha", nil)) } + @Test + func `trusted chatgpt app server projects recent codex rollout activity without an agent process`() async throws { + let now = Date() + let fixture = try Self.makeAdaptiveChatGPTFixture(now: now, rolloutAge: 30) + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let sessions = await fixture.scanner.scan( + now: now, + environment: fixture.environment, + includeFileOnlySessions: false) + let session = try #require(sessions.first) + + #expect(sessions.count == 1) + #expect(session.provider == .codex) + #expect(session.source == .cli) + #expect(session.state == .active) + #expect(session.pid == nil) + #expect(try abs(#require(session.lastActivityAt).timeIntervalSince(now.addingTimeInterval(-30))) < 0.01) + } + + @Test + func `idle chatgpt app server with a stale rollout does not produce coding activity`() async throws { + let now = Date() + let fixture = try Self.makeAdaptiveChatGPTFixture(now: now, rolloutAge: 31 * 60) + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let sessions = await fixture.scanner.scan( + now: now, + environment: fixture.environment, + includeFileOnlySessions: false) + + #expect(sessions.isEmpty) + } + + @Test + func `continuing an existing chatgpt codex rollout advances the adaptive activity signal`() async throws { + let now = Date() + let fixture = try Self.makeAdaptiveChatGPTFixture(now: now, rolloutAge: 30) + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let firstSessions = await fixture.scanner.scan( + now: now, + environment: fixture.environment, + includeFileOnlySessions: false) + let firstActivity = try #require(firstSessions.first?.lastActivityAt) + let nextActivity = now.addingTimeInterval(20) + try FileManager.default.setAttributes( + [.modificationDate: nextActivity], + ofItemAtPath: fixture.rollout.path) + + let continuedSessions = await fixture.scanner.scan( + now: nextActivity.addingTimeInterval(1), + environment: fixture.environment, + includeFileOnlySessions: false) + let continuedActivity = try #require(continuedSessions.first?.lastActivityAt) + + #expect(continuedSessions.first?.id == firstSessions.first?.id) + #expect(continuedActivity > firstActivity) + #expect(abs(continuedActivity.timeIntervalSince(nextActivity)) < 0.01) + } + + @Test + func `untrusted chatgpt app server cannot authorize adaptive rollout inspection`() async throws { + let now = Date() + let fixture = try Self.makeAdaptiveChatGPTFixture(now: now, rolloutAge: 30, appServerIsTrusted: false) + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let sessions = await fixture.scanner.scan( + now: now, + environment: fixture.environment, + includeFileOnlySessions: false) + + #expect(sessions.isEmpty) + } + + @Test + func `unrelated chatgpt named bundle cannot authorize adaptive rollout inspection`() async throws { + let now = Date() + let fixture = try Self.makeAdaptiveChatGPTFixture( + now: now, + rolloutAge: 30, + appServerExecutable: "/tmp/ChatGPT.app/Contents/Resources/codex") + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let sessions = await fixture.scanner.scan( + now: now, + environment: fixture.environment, + includeFileOnlySessions: false) + + #expect(sessions.isEmpty) + } + @Test func `local scanner parses only its newest configured rollout candidates`() async throws { let fileManager = FileManager.default @@ -261,6 +353,56 @@ struct CodexSessionRolloutTests { #expect(sessions.allSatisfy { $0.sessionName == nil }) } + private struct AdaptiveChatGPTFixture { + let root: URL + let rollout: URL + let scanner: LocalAgentSessionScanner + let environment: [String: String] + } + + private static func makeAdaptiveChatGPTFixture( + now: Date, + rolloutAge: TimeInterval, + appServerExecutable: String = "/Applications/ChatGPT.app/Contents/Resources/codex", + appServerIsTrusted: Bool = true) throws -> AdaptiveChatGPTFixture + { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("CodexSessionRolloutTests-chatgpt-\(UUID().uuidString)", isDirectory: true) + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy/MM/dd" + let codexHome = root.appendingPathComponent("codex-home", isDirectory: true) + let sessionDirectory = codexHome + .appendingPathComponent("sessions", isDirectory: true) + .appendingPathComponent(formatter.string(from: now), isDirectory: true) + try fileManager.createDirectory(at: sessionDirectory, withIntermediateDirectories: true) + + let source = try AgentSessionParserTests.fixtureURL("agent-session-rollout", extension: "jsonl") + let rollout = sessionDirectory.appendingPathComponent("rollout-chatgpt-existing.jsonl") + try fileManager.copyItem(at: source, to: rollout) + try fileManager.setAttributes( + [.modificationDate: now.addingTimeInterval(-rolloutAge)], + ofItemAtPath: rollout.path) + + let scanner = LocalAgentSessionScanner( + processOutputProvider: { _ in + "4234 1 Mon Jul 6 09:03:00 2026 \(appServerExecutable) " + + "-c features.code_mode_host=true app-server --analytics-default-enabled" + }, + cwdProvider: { _, _ in [:] }, + appServerTrustValidator: { _ in appServerIsTrusted }) + return AdaptiveChatGPTFixture( + root: root, + rollout: rollout, + scanner: scanner, + environment: [ + "CODEX_HOME": codexHome.path, + "HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + ]) + } + #if canImport(SQLite3) || canImport(CSQLite3) @Test func `scanner resolves relative sqlite homes for multiple session projects`() async throws { diff --git a/Tests/CodexBarTests/CostUsageScannerForkSplitPricingEvidenceTests.swift b/Tests/CodexBarTests/CostUsageScannerForkSplitPricingEvidenceTests.swift index 978b09f9f9..6a09298e84 100644 --- a/Tests/CodexBarTests/CostUsageScannerForkSplitPricingEvidenceTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerForkSplitPricingEvidenceTests.swift @@ -98,5 +98,147 @@ extension CostUsageScannerForkSplitTests { #expect(report.data.first?.modelBreakdowns?.first?.costUSD == nil) #expect(report.summary?.totalCostUSD == nil) } + + @Test + func `trace priority turns keep row ownership for rows persisted as standard`() throws { + let environment = try CostUsageTestEnvironment() + defer { environment.cleanup() } + + let day = try environment.makeLocalNoon(year: 2026, month: 8, day: 11) + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) + let dayKey = range.sinceKey + let model = "gpt-5.5" + let inputPerRow = 150_000 + let outputPerRow = 10 + let tokensPerRow = inputPerRow + outputPerRow + // The rollout only ever labelled these rows standard; the trace database is the sole + // source that knows the second turn was served on the priority tier. + let standardRow = CostUsageScanner.CodexUsageRow( + day: dayKey, + model: model, + turnID: "standard-turn", + eventIndex: 0, + input: inputPerRow, + cached: 0, + output: outputPerRow, + pricingModel: model, + pricingMode: "standard") + let tracePriorityRow = CostUsageScanner.CodexUsageRow( + day: dayKey, + model: model, + turnID: "trace-priority-turn", + eventIndex: 1, + input: inputPerRow, + cached: 0, + output: outputPerRow, + pricingModel: model, + pricingMode: "standard") + let usage = CostUsageScanner.makeFileUsage( + mtimeUnixMs: 1, + size: 1, + days: [dayKey: [model: [inputPerRow * 2, 0, outputPerRow * 2]]], + parsedBytes: 1, + codexStandardTokens: [dayKey: [model: tokensPerRow * 2]], + codexPriorityTokens: [dayKey: [model: 0]], + codexRows: [standardRow, tracePriorityRow], + codexScanComplete: true) + var cache = CostUsageCache() + cache.files = ["/trace-priority.jsonl": usage] + cache.days = usage.days + + let priorityTurns = [ + "trace-priority-turn": CostUsageScanner.CodexPriorityTurnMetadata( + threadID: nil, + turnID: "trace-priority-turn", + model: nil, + timestamp: nil), + ] + let evidence = CostUsageScanner.codexPricingModeEvidence( + usage: usage, + reconciledRows: CostUsageScanner.codexCanonicalPricingRows(usage).rows, + range: range, + priorityTurns: priorityTurns) + #expect(evidence.mismatchGroups.isEmpty) + + // The day aggregate crosses the long-context threshold, so the aggregate fallback cannot + // price this group: only the retained rows can, and distrusting them blanks the whole day. + #expect(CostUsagePricing.codexAggregateCostUSD( + model: model, + inputTokens: inputPerRow * 2, + cachedInputTokens: 0, + outputTokens: outputPerRow * 2) == nil) + + let report = CostUsageScanner.buildCodexReportFromCache( + cache: cache, + range: range, + priorityTurns: priorityTurns) + let standardCost = try #require(CostUsagePricing.codexCostUSD( + model: model, + inputTokens: inputPerRow, + cachedInputTokens: 0, + outputTokens: outputPerRow)) + let priorityCost = try #require(CostUsagePricing.codexPriorityCostUSD( + model: model, + inputTokens: inputPerRow, + outputTokens: outputPerRow)) + let entry = try #require(report.data.first) + let cost = try #require(entry.costUSD) + #expect(abs(cost - (standardCost + priorityCost)) < 0.000000001) + #expect(report.summary?.totalCostUSD != nil) + let breakdown = try #require(entry.modelBreakdowns?.first) + #expect(breakdown.priorityTokens == tokensPerRow) + #expect(breakdown.standardTokens == tokensPerRow) + } + + @Test + func `stale persisted mode split still invalidates row ownership`() throws { + let environment = try CostUsageTestEnvironment() + defer { environment.cleanup() } + + let day = try environment.makeLocalNoon(year: 2026, month: 8, day: 11) + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) + let dayKey = range.sinceKey + let model = "gpt-5.5" + let inputPerRow = 150_000 + let outputPerRow = 10 + let tokensPerRow = inputPerRow + outputPerRow + let rows = (0..<2).map { index in + CostUsageScanner.CodexUsageRow( + day: dayKey, + model: model, + turnID: "turn-\(index)", + eventIndex: index, + input: inputPerRow, + cached: 0, + output: outputPerRow, + pricingModel: model, + pricingMode: "standard") + } + // A copied fork prefix can leave a mode split that no classification of the retained rows + // reproduces; that evidence must keep distrusting the rows. + let usage = CostUsageScanner.makeFileUsage( + mtimeUnixMs: 1, + size: 1, + days: [dayKey: [model: [inputPerRow * 2, 0, outputPerRow * 2]]], + parsedBytes: 1, + codexStandardTokens: [dayKey: [model: tokensPerRow]], + codexPriorityTokens: [dayKey: [model: tokensPerRow]], + codexRows: rows, + codexScanComplete: true) + var cache = CostUsageCache() + cache.files = ["/stale-mode-split.jsonl": usage] + cache.days = usage.days + + let evidence = CostUsageScanner.codexPricingModeEvidence( + usage: usage, + reconciledRows: CostUsageScanner.codexCanonicalPricingRows(usage).rows, + range: range, + priorityTurns: [:]) + #expect(evidence.mismatchGroups == [CostUsageScanner.CodexDayModelKey(day: dayKey, model: model)]) + + let report = CostUsageScanner.buildCodexReportFromCache(cache: cache, range: range) + #expect(report.data.first?.costUSD == nil) + #expect(report.summary?.totalCostUSD == nil) + } } #endif diff --git a/Tests/CodexBarTests/CostUsageScannerTests.swift b/Tests/CodexBarTests/CostUsageScannerTests.swift index 3776baa84b..0b867d59ec 100644 --- a/Tests/CodexBarTests/CostUsageScannerTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerTests.swift @@ -508,6 +508,241 @@ struct CostUsageScannerTests { #expect(delta.rows.first?.reasoning == 3) } + @Test + func `codex cached tokens use maximum of both cache fields`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2025, month: 12, day: 20) + let iso = env.isoString(for: day) + let model = "openai/gpt-5.2-codex" + // cache_read_input_tokens is the authoritative field in this fixture; + // the legacy fallback would have silently used the stale zero instead. + let tokenCount: [String: Any] = [ + "type": "event_msg", + "timestamp": iso, + "payload": [ + "type": "token_count", + "info": [ + "last_token_usage": [ + "input_tokens": 100, + "cached_input_tokens": 0, + "cache_read_input_tokens": 25, + "output_tokens": 10, + ], + "model": model, + ], + ], + ] + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "cache-max.jsonl", + contents: env.jsonl([tokenCount])) + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) + + let parsed = CostUsageScanner.parseCodexFile(fileURL: fileURL, range: range) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let packed = parsed.days[dayKey]?["gpt-5.2-codex"] ?? [] + + #expect(packed.count >= 3) + #expect(packed[0] == 100) + #expect(packed[1] == 25) + #expect(packed[2] == 10) + } + + @Test + func `codex out-of-order stale snapshot does not double-count last usage`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2025, month: 12, day: 20) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let iso2 = env.isoString(for: day.addingTimeInterval(2)) + let model = "openai/gpt-5.2-codex" + let turnContext: [String: Any] = [ + "type": "turn_context", + "timestamp": iso0, + "payload": ["model": model], + ] + func tokenCount(_ timestamp: String, input: Int, output: Int) -> [String: Any] { + [ + "type": "event_msg", + "timestamp": timestamp, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": input, + "output_tokens": output, + ], + "last_token_usage": [ + "input_tokens": 5, + "output_tokens": 1, + ], + ], + ], + ] + } + // Normal increment, then a stale snapshot that regressed by roughly one + // increment, then a resume from the true watermark. + let lines = try env.jsonl([ + turnContext, + tokenCount(iso1, input: 10, output: 3), + tokenCount(iso2, input: 8, output: 2), + tokenCount(iso2, input: 15, output: 5), + ]) + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "stale-regression.jsonl", + contents: lines) + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) + + let parsed = CostUsageScanner.parseCodexFile(fileURL: fileURL, range: range) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let packed = parsed.days[dayKey]?["gpt-5.2-codex"] ?? [] + + // Stale detection skips the regressed snapshot without advancing its baseline; + // the resumed true watermark contributes a second 5-token increment. Counted + // usage is therefore 10/2, while the raw cumulative watermark remains 15/5. + #expect(packed.count >= 3) + #expect(packed[0] == 10) + #expect(packed[2] == 2) + } + + @Test + func `codex stale detection treats omitted reasoning as unknown`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2025, month: 12, day: 20) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let iso2 = env.isoString(for: day.addingTimeInterval(2)) + let model = "openai/gpt-5.2-codex" + let turnContext: [String: Any] = [ + "type": "turn_context", + "timestamp": iso0, + "payload": ["model": model], + ] + func tokenCount(_ timestamp: String, input: Int, output: Int, reasoning: Int?) -> [String: Any] { + var total: [String: Any] = [ + "input_tokens": input, + "output_tokens": output, + ] + if let reasoning { + total["reasoning_output_tokens"] = reasoning + } + return [ + "type": "event_msg", + "timestamp": timestamp, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": total, + "last_token_usage": [ + "input_tokens": 5, + "output_tokens": 1, + ], + ], + ], + ] + } + // The first snapshot includes reasoning; the resumed snapshot omits it. The + // omitted field must not count as a reasoning regression that hides the row. + let lines = try env.jsonl([ + turnContext, + tokenCount(iso1, input: 10, output: 3, reasoning: 1), + tokenCount(iso2, input: 15, output: 4, reasoning: nil), + ]) + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "stale-omitted-reasoning.jsonl", + contents: lines) + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) + + let parsed = CostUsageScanner.parseCodexFile(fileURL: fileURL, range: range) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let packed = parsed.days[dayKey]?["gpt-5.2-codex"] ?? [] + + #expect(packed.count >= 3) + #expect(packed[0] == 10) + // Both increments contribute one output unit each (1 + 1); the omitted + // reasoning field must not cause the second snapshot to be discarded. + #expect(packed[2] == 2) + } + + @Test + func `codex bare usage rows count exec aliases with cached input subtraction`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 8, day: 21) + let iso = env.isoString(for: day) + let model = "openai/gpt-5.2-codex" + + func bareUsage(model: String?, cachedTokens: Int) -> [String: Any] { + var usage: [String: Any] = [ + "prompt_tokens": 120, + "completion_tokens": 30, + ] + if cachedTokens > 0 { + usage["cached_tokens"] = cachedTokens + } + var line: [String: Any] = [ + "timestamp": iso, + "usage": usage, + ] + if let model { + line["model"] = model + } + return line + } + + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "bare-usage.jsonl", + contents: env.jsonl([ + bareUsage(model: model, cachedTokens: 20), + ["timestamp": iso, "data": ["usage": ["input": 10, "output": 4]]], + ])) + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) + + let parsed = CostUsageScanner.parseCodexFile(fileURL: fileURL, range: range) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + + #expect(parsed.days[dayKey]?["gpt-5.2-codex"] == [100, 20, 30]) + #expect(parsed.days[dayKey]?["unknown"] == [10, 0, 4]) + #expect(parsed.rows.count == 2) + } + + @Test + func `codex bare usage without timestamp uses prior accepted timestamp`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let dayOne = try env.makeLocalNoon(year: 2026, month: 8, day: 21) + let dayTwo = try env.makeLocalNoon(year: 2026, month: 8, day: 22) + let isoOne = env.isoString(for: dayOne) + let fileURL = try env.writeCodexSessionFile( + day: dayOne, + filename: "bare-usage-timestamp-fallback.jsonl", + contents: env.jsonl([ + ["timestamp": isoOne, "response": ["usage": ["input_tokens": 7, "output_tokens": 3]]], + ["result": ["usage": ["input_tokens": 5, "output_tokens": 1]]], + ])) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: .init(since: dayTwo, until: dayTwo)) + + // The timestamp-less result row must stay attributable to the last accepted day, + // not disappear when the requested report window is the following day. + let firstDayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: dayOne) + #expect(parsed.days[firstDayKey]?["unknown"] == [12, 0, 4]) + #expect(parsed.rows.map(\.day) == [firstDayKey, firstDayKey]) + } + @Test func `codex incremental parsing keeps current turn id`() throws { let env = try CostUsageTestEnvironment() @@ -619,6 +854,49 @@ struct CostUsageScannerTests { #expect(packed[2] == 5) } + @Test + func `codex json fallback applies maximum cache selection`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2025, month: 12, day: 20) + let iso = env.isoString(for: day) + // The fast byte parser declines an escaped cache-read key, and the huge + // integer also defeats its nil-coalescing fallback. The line therefore + // reaches the JSONSerialization fallback, where the legacy + // nil-coalescing selection would have used the present zero and + // undercounted cached input. + let hugeInteger = String(repeating: "9", count: 100) + let cacheReadKey = "cache_\(String(UnicodeScalar(0x72)))ead_input_tokens" + let usageJSONParts = [ + "{\"input_tokens\":\(hugeInteger)", + "\"cached_input_tokens\":0", + "\"\(cacheReadKey)\":25", + "\"output_tokens\":10}", + ] + let usageJSON = usageJSONParts.joined(separator: ",") + let eventJSONParts = [ + "{\"type\":\"event_msg\",\"timestamp\":\"\(iso)\"", + "\"payload\":{\"type\":\"token_count\",\"info\":", + "{\"last_token_usage\":\(usageJSON),\"model\":\"openai/gpt-5.5\"}}}", + ] + let line = eventJSONParts.joined() + #expect(line.contains("\n") == false) + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "fallback-cache-max.jsonl", + contents: line + "\n") + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) + + let parsed = CostUsageScanner.parseCodexFile(fileURL: fileURL, range: range) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] + + #expect(packed.count >= 3) + #expect(packed[1] == 25) + #expect(packed[2] == 10) + } + @Test func `claude incremental parsing reads appended lines only`() throws { let env = try CostUsageTestEnvironment() diff --git a/Tests/CodexBarTests/CostUsageStoreTests.swift b/Tests/CodexBarTests/CostUsageStoreTests.swift index 5636bb88fb..103f094112 100644 --- a/Tests/CodexBarTests/CostUsageStoreTests.swift +++ b/Tests/CodexBarTests/CostUsageStoreTests.swift @@ -1010,6 +1010,8 @@ extension CostUsageStoreTests { "b975eb705f905b9a", "47144baa8daccf52", "2d17f4981b78d07f", + "3c984b655688593f", + "5f8507161b23757c", ]) let predecessorHash = "43609cc56f76a003" let predecessorVersion = CostUsageStore.combinedSchemaVersion( diff --git a/Tests/CodexBarTests/CursorAntigravityLocalReaderTests.swift b/Tests/CodexBarTests/CursorAntigravityLocalReaderTests.swift new file mode 100644 index 0000000000..ffbce28174 --- /dev/null +++ b/Tests/CodexBarTests/CursorAntigravityLocalReaderTests.swift @@ -0,0 +1,138 @@ +import Foundation +import Testing +@testable import CodexBarCore + +/// Tokscale-compatible local readers: Cursor CSV caches +/// (`~/.config/tokscale/cursor-cache/usage*.csv`) and Antigravity session caches +/// (`~/.config/tokscale/antigravity-cache/sessions/*.jsonl`). +/// Header layouts mirror `tokscale/crates/tokscale-core/src/sessions/cursor.rs`: +/// - v1: Date,Model,Input (w/ Cache Write),Input (w/o Cache Write),Cache Read,Output Tokens,Total Tokens,Cost,Cost to +/// you +/// - v2: Date,Kind,Model,Max Mode,Input (w/ Cache Write),Input (w/o Cache Write),Cache Read,Output Tokens,Total +/// Tokens,Cost +/// - v3: Date,Cloud Agent ID,Automation ID,Kind,Model,Max Mode,Input (w/ Cache Write),Input (w/o Cache Write),Cache +/// Read,Output Tokens,Total Tokens,Cost +struct CursorAntigravityLocalReaderTests { + @Test + func `cursor v1 csv parses token buckets and cost`() throws { + let csv = """ + Date,Model,Input (w/ Cache Write),Input (w/o Cache Write),Cache Read,Output Tokens,Total Tokens,Cost,Cost to you + 2026-08-20T10:00:00.000Z,claude-sonnet-4-5,1200,800,400,300,1500,0.012,0 + """ + let url = try Self.writeTemporary(csv, extension: "csv") + defer { try? FileManager.default.removeItem(at: url) } + let rows = CursorLocalCSVReader.parseFile(at: url) + #expect(rows.count == 1) + let row = try #require(rows.first) + #expect(row.model == "claude-sonnet-4-5") + #expect(row.input == 800) + #expect(row.cacheRead == 400) + #expect(row.cacheWrite == 400) + #expect(row.output == 300) + #expect(row.cost == 0.012) + } + + @Test + func `cursor v2 csv parses kind rows`() throws { + let csv = [ + "Date,Kind,Model,Max Mode,Input (w/ Cache Write),Input (w/o Cache Write)," + + "Cache Read,Output Tokens,Total Tokens,Cost", + "2026-08-20T11:00:00Z,chat,gpt-5.4,on,500,500,0,50,550,0.001", + ].joined(separator: "\n") + "\n" + let url = try Self.writeTemporary(csv, extension: "csv") + defer { try? FileManager.default.removeItem(at: url) } + let rows = CursorLocalCSVReader.parseFile(at: url) + let row = try #require(rows.first) + #expect(row.model == "gpt-5.4") + #expect(row.input == 500) + #expect(row.cacheRead == 0) + #expect(row.cacheWrite == 0) + #expect(row.output == 50) + } + + @Test + func `cursor v3 csv parses cloud agent rows`() throws { + let csv = [ + "Date,Cloud Agent ID,Automation ID,Kind,Model,Max Mode," + + "Input (w/ Cache Write),Input (w/o Cache Write)," + + "Cache Read,Output Tokens,Total Tokens,Cost", + "2026-08-20,agent-123,,agent,claude-opus-4-6,off,900,700,200,120,1020,0.05", + ].joined(separator: "\n") + "\n" + let url = try Self.writeTemporary(csv, extension: "csv") + defer { try? FileManager.default.removeItem(at: url) } + let rows = CursorLocalCSVReader.parseFile(at: url) + let row = try #require(rows.first) + #expect(row.model == "claude-opus-4-6") + #expect(row.input == 700) + #expect(row.cacheRead == 200) + #expect(row.cacheWrite == 200) + #expect(row.output == 120) + #expect(row.cost == 0.05) + } + + @Test + func `cursor csv aggregation groups rows into day entries`() throws { + let csv = """ + Date,Model,Input (w/ Cache Write),Input (w/o Cache Write),Cache Read,Output Tokens,Total Tokens,Cost,Cost to you + 2026-08-20T10:00:00.000Z,claude-sonnet-4-5,1200,800,400,300,1500,0.012,0 + 2026-08-20T12:00:00.000Z,claude-sonnet-4-5,600,500,100,100,700,0.004,0 + 2026-08-21T09:00:00.000Z,gpt-5.4,0,0,0,40,40,0.001,0 + """ + let url = try Self.writeTemporary(csv, extension: "csv") + defer { try? FileManager.default.removeItem(at: url) } + let report = CursorLocalCSVReader.makeDailyReport( + from: CursorLocalCSVReader.parseFile(at: url)) + #expect(report.data.count == 2) + let first = try #require(report.data.first) + #expect(first.inputTokens == 1300) + #expect(first.cacheReadTokens == 500) + #expect(first.cacheCreationTokens == 500) + #expect(first.outputTokens == 400) + #expect(first.requestCount == 2) + #expect(first.costUSD ?? 0 == 0.016) + let breakdowns = try #require(first.modelBreakdowns) + #expect(breakdowns.count == 1) + #expect(breakdowns.first?.modelName == "claude-sonnet-4-5") + let summary = try #require(report.summary) + // The CSV's own Total Tokens column is authoritative when present + // (1500 + 700 + 40), rather than the recomputed bucket sum. + #expect(summary.totalTokens == 2240) + } + + @Test + func `antigravity cache jsonl aggregates usage with session model fallback`() throws { + // Derive timestamps from local noon so both events land on the same local day in any TZ. + let calendar = Calendar.current + let noon = calendar.date( + byAdding: .hour, value: 12, to: calendar.startOfDay(for: Date())) ?? Date() + let firstStamp = Int64(noon.timeIntervalSince1970 * 1000) + let secondStamp = firstStamp + 3_600_000 + let jsonl = [ + #"{"type":"session_meta","modelId":"test-model-a"}"#, + #"{"type":"usage","modelId":"test-model-a","input":100,"output":30,"cacheRead":50,"cacheWrite":10,"# + + #""timestamp":\#(firstStamp)}"#, + #"{"type":"usage","input":40,"output":10,"cacheRead":0,"cacheWrite":0,"timestamp":\#(secondStamp)}"#, + ].joined(separator: "\n") + "\n" + let url = try Self.writeTemporary(jsonl, extension: "jsonl") + defer { try? FileManager.default.removeItem(at: url) } + let entries = AntigravityLocalReader.parseJSONLCache(paths: [url]) + #expect(entries.count == 1) + let entry = try #require(entries.first) + #expect(entry.inputTokens == 140) + #expect(entry.outputTokens == 40) + #expect(entry.cacheReadTokens == 50) + #expect(entry.cacheCreationTokens == 10) + #expect(entry.requestCount == 2) + // The second event omits `modelId` and falls back to the session_meta model. + let breakdowns = try #require(entry.modelBreakdowns) + #expect(breakdowns.map(\.modelName) == ["test-model-a"]) + #expect(breakdowns.first?.requestCount == 2) + } + + private static func writeTemporary(_ contents: String, extension fileExtension: String) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("cursor-antigravity-reader-\(UUID().uuidString).\(fileExtension)") + try contents.write(to: url, atomically: true, encoding: .utf8) + return url + } +} diff --git a/Tests/CodexBarTests/GrokCreditsProxyFetcherTests.swift b/Tests/CodexBarTests/GrokCreditsProxyFetcherTests.swift index af7098a513..d188900f76 100644 --- a/Tests/CodexBarTests/GrokCreditsProxyFetcherTests.swift +++ b/Tests/CodexBarTests/GrokCreditsProxyFetcherTests.swift @@ -96,7 +96,7 @@ struct GrokCreditsProxyFetcherTests { } @Test - func `treats a period without usage as zero percent`() throws { + func `treats a period without usage as unknown`() throws { let snapshot = try GrokCreditsProxyFetcher.parseSnapshot( Data( """ @@ -109,7 +109,7 @@ struct GrokCreditsProxyFetcherTests { """.utf8)) let expectedReset = try Self.date("2026-08-13T00:00:00.123Z") - #expect(snapshot.usedPercent == 0) + #expect(snapshot.usedPercent == nil) #expect(snapshot.resetsAt == expectedReset) #expect(snapshot.subscriptionTier == nil) } @@ -134,7 +134,7 @@ struct GrokCreditsProxyFetcherTests { let expectedReset = try Self.date("2026-08-23T18:42:45.537749+00:00") #expect(snapshot.subscriptionTier == "SuperGrok Heavy") - #expect(snapshot.usedPercent == 0) + #expect(snapshot.usedPercent == nil) #expect(snapshot.resetsAt == expectedReset) } @@ -184,7 +184,7 @@ struct GrokCreditsProxyFetcherTests { """.utf8)) let expectedReset = try Self.date("2026-08-13T00:00:00Z") - #expect(snapshot.usedPercent == 0) + #expect(snapshot.usedPercent == nil) #expect(snapshot.subscriptionTier == "SuperGrok Heavy") #expect(snapshot.resetsAt == expectedReset) } @@ -231,7 +231,7 @@ struct GrokCreditsProxyFetcherTests { """.utf8)) let expectedReset = try Self.date("2026-08-13T00:00:00Z") - #expect(snapshot.usedPercent == 0) + #expect(snapshot.usedPercent == nil) #expect(snapshot.resetsAt == expectedReset) } diff --git a/Tests/CodexBarTests/IconRendererHideCrittersTests.swift b/Tests/CodexBarTests/IconRendererHideCrittersTests.swift index eb879f1b2c..e8b8eab26f 100644 --- a/Tests/CodexBarTests/IconRendererHideCrittersTests.swift +++ b/Tests/CodexBarTests/IconRendererHideCrittersTests.swift @@ -58,6 +58,77 @@ struct IconRendererHideCrittersTests { #expect(try self.pixels(decorated) != self.pixels(plain)) } + @Test + func `fill width tracks and clamps the reported percentage`() { + #expect(IconRenderer.fillWidthPixels(remaining: 46, rectWidth: 30) == 14) + #expect(IconRenderer.fillWidthPixels(remaining: -1, rectWidth: 30) == 0) + #expect(IconRenderer.fillWidthPixels(remaining: 0, rectWidth: 30) == 0) + #expect(IconRenderer.fillWidthPixels(remaining: 100, rectWidth: 30) == 30) + #expect(IconRenderer.fillWidthPixels(remaining: 120, rectWidth: 30) == 30) + } + + @Test + func `single quota layout follows provider policy even in combined style`() throws { + func image( + primary: Double?, + weekly: Double?, + policy: IconRenderer.QuotaLayoutPolicy) -> NSImage + { + IconRenderer.makeIcon( + primaryRemaining: primary, + weeklyRemaining: weekly, + creditsRemaining: nil, + stale: false, + style: .combined, + hideCritters: true, + quotaLayoutPolicy: policy) + } + + let compact = IconRenderer.QuotaLayoutPolicy.provider(.codex) + let reserved = IconRenderer.QuotaLayoutPolicy.provider(.claude) + let compactPrimary = image(primary: 46, weekly: nil, policy: compact) + let compactSecondary = image(primary: nil, weekly: 46, policy: compact) + let reservedPrimary = image(primary: 46, weekly: nil, policy: reserved) + + #expect(try self.pixels(compactPrimary) == self.pixels(compactSecondary)) + #expect(try self.pixels(compactPrimary) != self.pixels(reservedPrimary)) + } + + @Test + func `special and multi-value layouts remain unchanged`() throws { + func image( + primary: Double?, + weekly: Double?, + credits: Double? = nil, + policy: IconRenderer.QuotaLayoutPolicy) -> NSImage + { + IconRenderer.makeIcon( + primaryRemaining: primary, + weeklyRemaining: weekly, + creditsRemaining: credits, + stale: false, + style: .combined, + hideCritters: true, + quotaLayoutPolicy: policy) + } + + let compact = IconRenderer.QuotaLayoutPolicy.provider(.codex) + let reserved = IconRenderer.QuotaLayoutPolicy.provider(.claude) + let warp = IconRenderer.QuotaLayoutPolicy.provider(.warp) + + #expect(try self.pixels(image(primary: 46, weekly: 46, policy: compact)) + == self.pixels(image(primary: 46, weekly: 46, policy: reserved))) + #expect(try self.pixels(image(primary: 46, weekly: 0, policy: compact)) + == self.pixels(image(primary: 46, weekly: 0, policy: reserved))) + #expect(try self.pixels(image(primary: nil, weekly: nil, credits: 460, policy: compact)) + == self.pixels(image(primary: nil, weekly: nil, credits: 460, policy: reserved))) + #expect(try self.pixels(image(primary: 46, weekly: nil, policy: warp)) + == self.pixels(image(primary: 46, weekly: 0, policy: warp))) + + let unknown = image(primary: nil, weekly: nil, policy: compact) + #expect(try self.pixels(unknown).isEmpty == false) + } + @Test func `hiding critters is a no-op for an undecorated style`() throws { // Cursor has no critter twist, so the flag must not alter its bars. diff --git a/Tests/CodexBarTests/IconRendererScreenshotRenderTests.swift b/Tests/CodexBarTests/IconRendererScreenshotRenderTests.swift new file mode 100644 index 0000000000..2b27ff0e41 --- /dev/null +++ b/Tests/CodexBarTests/IconRendererScreenshotRenderTests.swift @@ -0,0 +1,87 @@ +import AppKit +import XCTest +@testable import CodexBar + +/// Developer tool, skipped by default: renders a synthetic single-quota icon for PR proof. +/// +/// Run with: +/// CODEXBAR_ICON_SCREENSHOT_DIR=docs/screenshots \ +/// swift test --filter IconRendererScreenshotRenderTests +@MainActor +final class IconRendererScreenshotRenderTests: XCTestCase { + private static let canvasSize = NSSize(width: 360, height: 240) + + func test_renderSyntheticSingleQuotaIcon() throws { + guard let dir = ProcessInfo.processInfo.environment["CODEXBAR_ICON_SCREENSHOT_DIR"] else { + throw XCTSkip("Set CODEXBAR_ICON_SCREENSHOT_DIR to render the synthetic icon proof.") + } + let directory = URL(fileURLWithPath: dir, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + let icon = IconRenderer.makeIcon( + primaryRemaining: 46, + weeklyRemaining: nil, + creditsRemaining: nil, + stale: false, + style: .combined, + hideCritters: true, + quotaLayoutPolicy: .provider(.codex)) + let data = try XCTUnwrap(Self.proofPNG(icon: icon), "synthetic icon proof render failed") + let url = directory.appendingPathComponent("codex-single-quota-icon.png") + try data.write(to: url, options: .atomic) + print("Wrote \(url.lastPathComponent)") + } + + private static func proofPNG(icon: NSImage) -> Data? { + guard let representation = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: Int(canvasSize.width), + pixelsHigh: Int(canvasSize.height), + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0), + let context = NSGraphicsContext(bitmapImageRep: representation) + else { return nil } + representation.size = Self.canvasSize + + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = context + NSColor(srgbRed: 0.10, green: 0.11, blue: 0.12, alpha: 1).setFill() + NSRect(origin: .zero, size: Self.canvasSize).fill() + + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = .center + let titleAttributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 26, weight: .semibold), + .foregroundColor: NSColor.white, + .paragraphStyle: paragraph, + ] + let subtitleAttributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.monospacedDigitSystemFont(ofSize: 22, weight: .regular), + .foregroundColor: NSColor(white: 0.72, alpha: 1), + .paragraphStyle: paragraph, + ] + NSString(string: "Synthetic proof").draw( + in: NSRect(x: 24, y: 192, width: 312, height: 36), + withAttributes: titleAttributes) + NSString(string: "46% remaining").draw( + in: NSRect(x: 24, y: 158, width: 312, height: 32), + withAttributes: subtitleAttributes) + + context.imageInterpolation = .none + icon.isTemplate = false + icon.draw( + in: NSRect(x: 108, y: 20, width: 144, height: 144), + from: NSRect(origin: .zero, size: icon.size), + operation: .sourceOver, + fraction: 1, + respectFlipped: false, + hints: nil) + NSGraphicsContext.restoreGraphicsState() + return representation.representation(using: .png, properties: [.interlaced: false]) + } +} diff --git a/Tests/CodexBarTests/KeychainCacheStoreTests.swift b/Tests/CodexBarTests/KeychainCacheStoreTests.swift index 000b551bbf..d3e8d01c91 100644 --- a/Tests/CodexBarTests/KeychainCacheStoreTests.swift +++ b/Tests/CodexBarTests/KeychainCacheStoreTests.swift @@ -324,6 +324,90 @@ struct KeychainCacheStoreTests { } } + @Test + func `bundled ad hoc builds keep cookie cache in process memory`() { + KeychainCacheStore.resetDisabledAccessMemoryStoreForTesting() + defer { KeychainCacheStore.resetDisabledAccessMemoryStoreForTesting() } + + let service = "adhoc-memory-\(UUID().uuidString)" + let key = KeychainCacheStore.Key.cookie(provider: .claude) + let entry = TestEntry(value: "sessionKey=memory", storedAt: Date(timeIntervalSince1970: 4)) + + KeychainCacheStore.withBundledAdHocProcessForTesting(true) { + KeychainCacheStore.withServiceOverrideForTesting(service) { + #expect(KeychainCacheStore.storeResult(key: key, entry: entry)) + #expect(self.loadedEntry(for: key) == entry) + #expect(KeychainCacheStore.keys(category: "cookie") == [key]) + #expect(KeychainCacheStore.clearResult(key: key) == .removed) + #expect(self.loadedEntry(for: key) == nil) + } + } + } + + @Test + func `certificate signed builds keep cookie cache in persistent store`() { + KeychainCacheStore.resetDisabledAccessMemoryStoreForTesting() + KeychainCacheStore.setTestStoreForTesting(true) + defer { + KeychainCacheStore.setTestStoreForTesting(false) + KeychainCacheStore.resetDisabledAccessMemoryStoreForTesting() + } + + let service = "signed-persistent-\(UUID().uuidString)" + let key = KeychainCacheStore.Key.cookie(provider: .claude) + let entry = TestEntry(value: "sessionKey=persistent", storedAt: Date(timeIntervalSince1970: 5)) + + KeychainCacheStore.withBundledAdHocProcessForTesting(false) { + KeychainCacheStore.withServiceOverrideForTesting(service) { + #expect(KeychainCacheStore.storeResult(key: key, entry: entry)) + #expect(self.loadedEntry(for: key) == entry) + } + } + + KeychainCacheStore.withBundledAdHocProcessForTesting(true) { + KeychainCacheStore.withServiceOverrideForTesting(service) { + #expect(self.loadedEntry(for: key) == nil) + } + } + + KeychainCacheStore.withBundledAdHocProcessForTesting(false) { + KeychainCacheStore.withServiceOverrideForTesting(service) { + #expect(self.loadedEntry(for: key) == entry) + #expect(KeychainCacheStore.clearResult(key: key) == .removed) + } + } + } + + @Test + func `code signing certificate detection recognizes signed system code`() { + #expect(KeychainCacheStore.hasCodeSigningCertificate(at: URL(fileURLWithPath: "/usr/bin/security"))) + #expect(!KeychainCacheStore.hasCodeSigningCertificate( + at: URL(fileURLWithPath: "/path/that/does/not/exist"))) + } + + @Test + func `bundled ad hoc builds do not move OAuth credentials into memory`() { + KeychainCacheStore.resetDisabledAccessMemoryStoreForTesting() + defer { + KeychainCacheStore.resetDisabledAccessMemoryStoreForTesting() + KeychainAccessGate.resetOverrideForTesting() + } + + let service = "adhoc-memory-oauth-\(UUID().uuidString)" + let key = KeychainCacheStore.Key.oauth(provider: .claude) + let entry = TestEntry(value: "synthetic-oauth-credential", storedAt: Date(timeIntervalSince1970: 5)) + + KeychainAccessGate.withTaskOverrideForTesting(true) { + KeychainCacheStore.withBundledAdHocProcessForTesting(true) { + KeychainCacheStore.withServiceOverrideForTesting(service) { + #expect(!KeychainCacheStore.storeResult(key: key, entry: entry)) + #expect(self.loadedEntry(for: key) == nil) + #expect(KeychainCacheStore.keysResult(category: "oauth") == .failed) + } + } + } + } + @Test func `disabled keychain access does not retain OAuth entries in memory`() { KeychainCacheStore.resetDisabledAccessMemoryStoreForTesting() diff --git a/Tests/CodexBarTests/KeychainNoUIQueryTests.swift b/Tests/CodexBarTests/KeychainNoUIQueryTests.swift index 7df8b2ea60..77e9fbc40c 100644 --- a/Tests/CodexBarTests/KeychainNoUIQueryTests.swift +++ b/Tests/CodexBarTests/KeychainNoUIQueryTests.swift @@ -50,6 +50,25 @@ struct KeychainNoUIQueryTests { #expect((query[kSecUseAuthenticationUI as String] as? String) == self.resolveSecurityUIFailValue()) } + @Test + func `generic password preflight memo is scoped to one operation`() { + var checkCount = 0 + + KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in + checkCount += 1 + return .allowed + } operation: { + KeychainAccessPreflight.withMemoizedGenericPasswordChecks { + _ = KeychainAccessPreflight.checkGenericPassword(service: "Chrome Safe Storage", account: "Chrome") + _ = KeychainAccessPreflight.checkGenericPassword(service: "Chrome Safe Storage", account: "Chrome") + _ = KeychainAccessPreflight.checkGenericPassword(service: "Chrome Safe Storage", account: "Canary") + } + _ = KeychainAccessPreflight.checkGenericPassword(service: "Chrome Safe Storage", account: "Chrome") + } + + #expect(checkCount == 3) + } + @Test func `decrypt ACL requires successful code signature validation without a prompt selector`() { #expect(KeychainAccessPreflight.decryptACLAllowsCurrentProcess( diff --git a/Tests/CodexBarTests/ModelsDevPricingTests.swift b/Tests/CodexBarTests/ModelsDevPricingTests.swift index a0da2f6370..9d9c156dec 100644 --- a/Tests/CodexBarTests/ModelsDevPricingTests.swift +++ b/Tests/CodexBarTests/ModelsDevPricingTests.swift @@ -1262,6 +1262,60 @@ extension ModelsDevPricingTests { #expect(reloaded.artifact == nil) } + @Test + func `memo invalidates when cache file size changes`() throws { + let root = try Self.cacheRoot() + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: Date(), cacheRoot: root) + let url = ModelsDevCache.cacheFileURL(cacheRoot: root) + let pinnedDate = Date(timeIntervalSince1970: 1_700_000_000) + try FileManager.default.setAttributes([.modificationDate: pinnedDate], ofItemAtPath: url.path) + let primed = try #require(ModelsDevCache.load(cacheRoot: root).artifact) + let originalSize = try #require( + try (FileManager.default.attributesOfItem(atPath: url.path)[.size]) as? NSNumber).intValue + + // Same pinned mtime, larger invalid payload: a memo keyed only on mtime would still hit. + try Data(repeating: 0x7B, count: originalSize + 16).write(to: url) + try FileManager.default.setAttributes([.modificationDate: pinnedDate], ofItemAtPath: url.path) + + let reloaded = ModelsDevCache.load(cacheRoot: root) + #expect(reloaded.artifact != primed) + #expect(reloaded.error == .invalidJSON) + } + + @Test + func `memo invalidates when cache file mtime changes`() throws { + let root = try Self.cacheRoot() + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: Date(), cacheRoot: root) + let url = ModelsDevCache.cacheFileURL(cacheRoot: root) + let pinnedDate = Date(timeIntervalSince1970: 1_700_000_000) + try FileManager.default.setAttributes([.modificationDate: pinnedDate], ofItemAtPath: url.path) + let primed = try #require(ModelsDevCache.load(cacheRoot: root).artifact) + + let size = try #require( + try (FileManager.default.attributesOfItem(atPath: url.path)[.size]) as? NSNumber).intValue + try Data(repeating: 0, count: size).write(to: url) + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSince1970: 1_700_000_100)], + ofItemAtPath: url.path) + + let reloaded = ModelsDevCache.load(cacheRoot: root) + #expect(reloaded.artifact != primed) + #expect(reloaded.error == .invalidJSON) + } + + @Test + func `load metadata check is one stat per load`() throws { + let root = try Self.cacheRoot() + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: Date(), cacheRoot: root) + let recorder = ModelsDevCache.MetadataReadRecorder() + ModelsDevCache.withMetadataReadRecorderForTesting(recorder) { + _ = ModelsDevCache.load(cacheRoot: root) + #expect(recorder.snapshot() == 1) + _ = ModelsDevCache.load(cacheRoot: root) + #expect(recorder.snapshot() == 2) + } + } + @Test func `client fetches with mock transport`() async throws { let data = try Self.fixtureData() diff --git a/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift b/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift index 51f893cf9b..1d6f476610 100644 --- a/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift +++ b/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift @@ -1,7 +1,7 @@ -import CodexBarCore import Foundation import Testing @testable import CodexBar +@testable import CodexBarCore struct OpenCodexUsageFanOutTests { @Test func `snapshotsBySubscription routes openai spend into codex`() throws { @@ -168,4 +168,680 @@ struct OpenCodexUsageFanOutTests { let result = SpendDashboardSource.mergingOpenCodexInputs([dummy], request: request) #expect(!result.contains(where: { $0.id == SpendDashboardModel.openCodexSourceID })) } + + @Test + func `snapshot matches a naive per-entry reference across DST and overlays`() throws { + let calendar = try Self.losAngelesCalendar() + let now = try Self.date(calendar, DateComponents(year: 2026, month: 11, day: 2, hour: 12, minute: 0)) + let customPricing = CostUsageCustomPricing.parse(Data(""" + { + "openai/gpt-5.4": { "input": 1.5, "output": 6, "cacheRead": 0.15, "cacheWrite": 1.875 } + } + """.utf8)) + let firstFallBack = try Self.date(calendar, DateComponents(year: 2026, month: 11, day: 1, hour: 1, minute: 30)) + let entries = try [ + OpenCodexUsageEntry( + requestID: "dup", + timestamp: Self.date(calendar, DateComponents(year: 2026, month: 11, day: 1, hour: 10, minute: 0)), + provider: "openai", + model: "gpt-5.4", + usageStatus: .reported, + conversationID: "chat-dup", + usage: OpenCodexTokenUsage(inputTokens: 1, outputTokens: 1, totalTokens: 2), + totalTokens: 2), + OpenCodexUsageEntry( + requestID: "dup", + timestamp: Self.date(calendar, DateComponents(year: 2026, month: 11, day: 1, hour: 10, minute: 5)), + provider: "openai", + model: "gpt-5.4", + usageStatus: .reported, + conversationID: "chat-dup", + usage: OpenCodexTokenUsage( + inputTokens: 80, + outputTokens: 20, + cacheReadInputTokens: 10, + cacheCreationInputTokens: 5, + totalTokens: 100), + totalTokens: 100), + OpenCodexUsageEntry( + requestID: "spring", + timestamp: Self.date(calendar, DateComponents(year: 2026, month: 3, day: 8, hour: 1, minute: 30)), + provider: "openai", + model: "gpt-5.4", + usageStatus: .reported, + conversationID: "chat-dst", + usage: OpenCodexTokenUsage(inputTokens: 40, outputTokens: 10, totalTokens: 50), + totalTokens: 50), + OpenCodexUsageEntry( + requestID: "spring-after", + timestamp: Self.date(calendar, DateComponents(year: 2026, month: 3, day: 8, hour: 3, minute: 0)), + provider: "openai", + model: "gpt-5.4", + usageStatus: .estimated, + conversationID: "chat-dst", + usage: OpenCodexTokenUsage(inputTokens: 20, outputTokens: 4, totalTokens: 24), + totalTokens: 24), + OpenCodexUsageEntry( + requestID: "fallback-first", + timestamp: firstFallBack, + provider: "opencode-go", + model: "opencode-go/gpt-5.2", + usageStatus: .reported, + conversationID: "chat-fallback", + usage: OpenCodexTokenUsage(inputTokens: 12, outputTokens: 3, totalTokens: 15), + totalTokens: 15), + OpenCodexUsageEntry( + requestID: "fallback-second", + timestamp: firstFallBack.addingTimeInterval(3600), + provider: "opencode-go", + model: "opencode-go/gpt-5.2", + usageStatus: .reported, + conversationID: "chat-fallback", + usage: OpenCodexTokenUsage(inputTokens: 8, outputTokens: 2, totalTokens: 10), + totalTokens: 10), + OpenCodexUsageEntry( + requestID: "unreported", + timestamp: Self.date(calendar, DateComponents(year: 2026, month: 11, day: 2, hour: 9, minute: 0)), + provider: "openai", + model: "gpt-5.4", + usageStatus: .unreported, + conversationID: "chat-today"), + OpenCodexUsageEntry( + requestID: "unsupported", + timestamp: Self.date(calendar, DateComponents(year: 2026, month: 11, day: 2, hour: 9, minute: 15)), + provider: "openai", + model: "gpt-5.4", + usageStatus: .unsupported, + conversationID: "chat-today", + usage: OpenCodexTokenUsage(inputTokens: 4, outputTokens: 1, totalTokens: 5), + totalTokens: 5), + OpenCodexUsageEntry( + requestID: "unknown-estimated", + timestamp: Self.date(calendar, DateComponents(year: 2026, month: 11, day: 2, hour: 9, minute: 30)), + provider: "openai", + model: "not-a-priced-model-xyz", + usageStatus: .estimated, + conversationID: "chat-today", + usage: OpenCodexTokenUsage(inputTokens: 6, outputTokens: 1, totalTokens: 7), + totalTokens: 7), + OpenCodexUsageEntry( + requestID: "outside-window", + timestamp: Self.date(calendar, DateComponents(year: 2024, month: 1, day: 1, hour: 12, minute: 0)), + provider: "openai", + model: "gpt-5.4", + usageStatus: .reported, + usage: OpenCodexTokenUsage(inputTokens: 999, outputTokens: 1, totalTokens: 1000), + totalTokens: 1000), + OpenCodexUsageEntry( + requestID: "catalog-priced", + timestamp: now, + provider: "openai", + model: "gpt-5.2", + usageStatus: .reported, + conversationID: "chat-catalog", + usage: OpenCodexTokenUsage(inputTokens: 1000, outputTokens: 1000, totalTokens: 2000), + totalTokens: 2000), + ] + + let root = try Self.modelsDevCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + let fixtureCatalog = Self.fixturePricingCatalog() + #expect(ModelsDevCache.save(catalog: fixtureCatalog, fetchedAt: now, cacheRoot: root)) + let loadedCatalog = try #require(ModelsDevCache.load(cacheRoot: root).artifact?.catalog) + + let snapshot = OpenCodexUsageAggregator.snapshot( + entries: entries, + now: now, + historyDays: 365, + calendar: calendar, + customPricing: customPricing, + modelsDevCatalog: loadedCatalog) + let reference = OpenCodexUsageSnapshotReference.snapshot( + entries: entries, + now: now, + historyDays: 365, + calendar: calendar, + customPricing: customPricing, + modelsDevCacheRoot: root) + + #expect(snapshot == reference) + #expect(snapshot.daily.count >= 3) + #expect(snapshot.hourly.count >= 5) + #expect(snapshot.sessions.contains { $0.sessionID == "chat-dup" && $0.requestCount == 1 }) + + let catalogPriced = try #require( + snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.first { $0.modelName == "gpt-5.2" }) + let catalogCost = try #require(catalogPriced.costUSD) + let bundledCost = CostUsagePricing.codexCostUSD( + model: "gpt-5.2", + inputTokens: 1000, + cachedInputTokens: 0, + outputTokens: 1000, + cacheWriteInputTokens: 0, + pricingDate: now, + modelsDevCatalog: ModelsDevCatalog(providers: [:]), + customPricing: .empty) + #expect(catalogCost != bundledCost) + } + + @Test + func `snapshot resolves the models.dev catalog once for many entries`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let now = Date(timeIntervalSince1970: 1_784_179_200) + let entryCount = 60 + let entries = (0.. Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + return calendar + } + + private static func santiagoCalendar() throws -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "America/Santiago")) + return calendar + } + + private static func date(_ calendar: Calendar, _ components: DateComponents) throws -> Date { + try #require(calendar.date(from: components)) + } + + private static func assertDayAndHourMemos( + calendar: Calendar, + now: Date, + samples: [(id: String, timestamp: Date, tokens: Int)]) throws + { + let entries = samples.map { sample in + OpenCodexUsageEntry( + requestID: sample.id, + timestamp: sample.timestamp, + provider: "openai", + model: "gpt-5.4", + usageStatus: .reported, + usage: OpenCodexTokenUsage( + inputTokens: sample.tokens, + outputTokens: 0, + totalTokens: sample.tokens), + totalTokens: sample.tokens) + } + let snapshot = OpenCodexUsageAggregator.snapshot( + entries: entries, + now: now, + historyDays: 365, + calendar: calendar) + for sample in samples { + let expectedDay = CostUsageLocalDay.key(from: sample.timestamp, calendar: calendar) + let expectedHour = calendar.dateInterval(of: .hour, for: sample.timestamp)?.start + ?? sample.timestamp + let day = try #require(snapshot.daily.first { $0.date == expectedDay }) + let hour = try #require(snapshot.hourly.first { $0.hour == expectedHour }) + #expect(day.totalTokens ?? 0 >= sample.tokens) + #expect(hour.totalTokens == sample.tokens) + #expect(hour.hour == expectedHour) + } + } + + private static func modelsDevCacheRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-opencodex-modelsdev-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + private static func fixturePricingCatalog() -> ModelsDevCatalog { + ModelsDevCatalog(providers: [ + "openai": ModelsDevProvider( + id: "openai", + name: "OpenAI", + models: [ + "gpt-5.2": ModelsDevModel( + id: "gpt-5.2", + name: nil, + cost: ModelsDevCost(input: 99, output: 199), + limit: nil), + "gpt-5.4": ModelsDevModel( + id: "gpt-5.4", + name: nil, + cost: ModelsDevCost(input: 88, output: 188), + limit: nil), + ]), + "opencode-go": ModelsDevProvider( + id: "opencode-go", + name: nil, + models: [ + "gpt-5.2": ModelsDevModel( + id: "gpt-5.2", + name: nil, + cost: ModelsDevCost(input: 50, output: 80), + limit: nil), + ]), + ]) + } +} + +private enum OpenCodexUsageSnapshotReference { + static func snapshot( + entries: [OpenCodexUsageEntry], + now: Date, + historyDays: Int, + calendar: Calendar, + customPricing: CostUsageCustomPricing, + modelsDevCacheRoot: URL? = nil) -> CostUsageTokenSnapshot + { + let days = max(1, min(365, historyDays)) + let today = calendar.startOfDay(for: now) + let windowStart = calendar.date(byAdding: .day, value: -(days - 1), to: today) ?? today + var unique: [String: OpenCodexUsageEntry] = [:] + for entry in entries { + unique[entry.requestID] = entry + } + let windowed = unique.values.filter { $0.timestamp >= windowStart && $0.timestamp <= now } + .sorted { lhs, rhs in + if lhs.timestamp != rhs.timestamp { + return lhs.timestamp < rhs.timestamp + } + return lhs.requestID < rhs.requestID + } + + var daysByKey: [String: OpenCodexUsageAggregator.DayAccumulator] = [:] + var sessions: [String: OpenCodexUsageAggregator.SessionAccumulator] = [:] + var hoursByStart: [Date: OpenCodexUsageAggregator.HourAccumulator] = [:] + for entry in windowed { + let cost = Self.listPriceUSD( + entry: entry, + customPricing: customPricing, + modelsDevCacheRoot: modelsDevCacheRoot) + let dayKey = CostUsageLocalDay.key(from: entry.timestamp, calendar: calendar) + var day = daysByKey[dayKey] ?? OpenCodexUsageAggregator.DayAccumulator() + Self.merge(entry, cost: cost, into: &day) + daysByKey[dayKey] = day + + let sessionID = entry.conversationID ?? entry.requestID + var session = sessions[sessionID] ?? OpenCodexUsageAggregator.SessionAccumulator() + session.lastActivity = max(session.lastActivity, entry.timestamp) + session.requests += 1 + Self.merge(entry, cost: cost, into: &session) + sessions[sessionID] = session + + let hour = calendar.dateInterval(of: .hour, for: entry.timestamp)?.start ?? entry.timestamp + var hourBucket = hoursByStart[hour] ?? OpenCodexUsageAggregator.HourAccumulator() + Self.merge(entry, cost: cost, into: &hourBucket) + hoursByStart[hour] = hourBucket + } + + let daily = daysByKey.keys.sorted().compactMap { key -> CostUsageDailyReport.Entry? in + guard let day = daysByKey[key] else { return nil } + return Self.entry(dayKey: key, day: day) + } + let sessionRows = sessions.keys.sorted().compactMap { key -> CostUsageSessionBreakdown? in + guard let session = sessions[key] else { return nil } + return CostUsageSessionBreakdown( + sessionID: key, + lastActivity: session.lastActivity, + inputTokens: session.input, + cachedInputTokens: session.cacheRead, + outputTokens: session.output, + reasoningTokens: session.reasoning, + totalTokens: session.tokens, + requestCount: session.requests, + costUSD: session.cost, + modelBreakdowns: Self.modelBreakdowns(session.models)) + } + .sorted { lhs, rhs in + if lhs.lastActivity != rhs.lastActivity { + return lhs.lastActivity > rhs.lastActivity + } + return lhs.sessionID < rhs.sessionID + } + let hourly = hoursByStart.keys.sorted().map { hour in + let bucket = hoursByStart[hour] ?? OpenCodexUsageAggregator.HourAccumulator() + return CostUsageHourlyEntry( + hour: hour, + totalTokens: bucket.sawTokens ? bucket.tokens : nil, + costUSD: bucket.sawCost ? bucket.cost : nil) + } + let todayEntry = CostUsageTokenSnapshot.entry( + in: daily, + forLocalDayContaining: now, + calendar: calendar) + let windowSummary = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + historyDays: days, + daily: daily, + sessions: Array(sessionRows.prefix(64)), + updatedAt: now) + .summary(forLastDays: min(30, days), calendar: calendar) + return CostUsageTokenSnapshot( + sessionTokens: todayEntry?.totalTokens ?? (daily.isEmpty ? nil : 0), + sessionCostUSD: todayEntry?.costUSD ?? (daily.isEmpty ? nil : 0), + sessionRequests: todayEntry?.requestCount ?? (daily.isEmpty ? nil : 0), + last30DaysTokens: windowSummary.totalTokens, + last30DaysCostUSD: windowSummary.totalCostUSD, + last30DaysRequests: windowSummary.totalRequests, + historyDays: days, + historyLabel: "OpenCodex usage.jsonl", + costProvenance: .listPriceEstimate, + daily: daily, + sessions: Array(sessionRows.prefix(64)), + hourly: hourly, + updatedAt: now) + } + + private static func merge( + _ entry: OpenCodexUsageEntry, + cost: Double?, + into day: inout OpenCodexUsageAggregator.DayAccumulator) + { + let usage = entry.usage + if let input = usage?.inputTokens { + day.input += input + day.sawInput = true + } + if let output = usage?.outputTokens { + day.output += output + day.sawOutput = true + } + if let cacheRead = usage?.cacheReadTokens { + day.cacheRead += cacheRead + day.sawCacheRead = true + } + if let cacheCreation = usage?.cacheCreationInputTokens { + day.cacheCreation += cacheCreation + day.sawCacheCreation = true + } + if let reasoning = usage?.reasoningOutputTokens { + day.reasoning += reasoning + day.sawReasoning = true + } + if let tokens = entry.resolvedTotalTokens { + day.tokens += tokens + day.sawTokens = true + } + day.priced += entry.usageStatus == .reported ? 1 : 0 + day.estimated += entry.usageStatus == .estimated ? 1 : 0 + day.unmetered += entry.usageStatus == .unsupported ? 1 : 0 + day.unpriced += entry.usageStatus == .unreported ? 1 : 0 + if let cost { + day.cost += cost + day.sawCost = true + } else if entry.usageStatus == .reported { + day.unpriced += 1 + if day.priced > 0 { + day.priced -= 1 + } + } else if entry.usageStatus == .estimated { + day.unpriced += 1 + if day.estimated > 0 { + day.estimated -= 1 + } + } + var model = day.models[entry.model] ?? OpenCodexUsageAggregator.ModelAccumulator() + Self.merge(entry, cost: cost, into: &model) + day.models[entry.model] = model + } + + private static func merge( + _ entry: OpenCodexUsageEntry, + cost: Double?, + into session: inout OpenCodexUsageAggregator.SessionAccumulator) + { + session.input = self.add(session.input, entry.usage?.inputTokens) + session.output = self.add(session.output, entry.usage?.outputTokens) + session.cacheRead = self.add(session.cacheRead, entry.usage?.cacheReadTokens) + session.reasoning = self.add(session.reasoning, entry.usage?.reasoningOutputTokens) + session.tokens = self.add(session.tokens, entry.resolvedTotalTokens) + session.cost = self.add(session.cost, cost) + var model = session.models[entry.model] ?? OpenCodexUsageAggregator.ModelAccumulator() + Self.merge(entry, cost: cost, into: &model) + session.models[entry.model] = model + } + + private static func merge( + _ entry: OpenCodexUsageEntry, + cost: Double?, + into hour: inout OpenCodexUsageAggregator.HourAccumulator) + { + if let tokens = entry.resolvedTotalTokens { + hour.tokens += tokens + hour.sawTokens = true + } + if let cost { + hour.cost += cost + hour.sawCost = true + } + } + + private static func merge( + _ entry: OpenCodexUsageEntry, + cost: Double?, + into model: inout OpenCodexUsageAggregator.ModelAccumulator) + { + model.input = self.add(model.input, entry.usage?.inputTokens) + model.output = self.add(model.output, entry.usage?.outputTokens) + model.cacheRead = self.add(model.cacheRead, entry.usage?.cacheReadTokens) + model.cacheCreation = self.add(model.cacheCreation, entry.usage?.cacheCreationInputTokens) + model.reasoning = self.add(model.reasoning, entry.usage?.reasoningOutputTokens) + if let tokens = entry.resolvedTotalTokens { + model.tokens += tokens + model.sawTokens = true + } + if let cost { + model.cost += cost + model.sawCost = true + } + } + + private static func entry( + dayKey: String, + day: OpenCodexUsageAggregator.DayAccumulator) -> CostUsageDailyReport.Entry + { + CostUsageDailyReport.Entry( + date: dayKey, + inputTokens: day.sawInput ? day.input : nil, + outputTokens: day.sawOutput ? day.output : nil, + cacheReadTokens: day.sawCacheRead ? day.cacheRead : nil, + cacheCreationTokens: day.sawCacheCreation ? day.cacheCreation : nil, + reasoningTokens: day.sawReasoning ? day.reasoning : nil, + totalTokens: day.sawTokens ? day.tokens : nil, + requestCount: day.priced + day.unpriced + day.unmetered + day.estimated, + costUSD: day.sawCost ? day.cost : nil, + modelsUsed: day.models.keys.sorted(), + modelBreakdowns: self.modelBreakdowns(day.models), + unpricedRequestCount: day.unpriced, + unmeteredRequestCount: day.unmetered, + estimatedRequestCount: day.estimated) + } + + private static func modelBreakdowns( + _ models: [String: OpenCodexUsageAggregator.ModelAccumulator]) -> [CostUsageDailyReport.ModelBreakdown] + { + models.keys.sorted().map { name in + let model = models[name] ?? OpenCodexUsageAggregator.ModelAccumulator() + return CostUsageDailyReport.ModelBreakdown( + modelName: name, + costUSD: model.sawCost ? model.cost : nil, + totalTokens: model.sawTokens ? model.tokens : nil, + inputTokens: model.input, + outputTokens: model.output, + cacheReadTokens: model.cacheRead, + cacheCreationTokens: model.cacheCreation, + reasoningTokens: model.reasoning) + } + } + + private static func listPriceUSD( + entry: OpenCodexUsageEntry, + customPricing: CostUsageCustomPricing, + modelsDevCacheRoot: URL?) -> Double? + { + guard entry.usageStatus == .reported || entry.usageStatus == .estimated else { return nil } + let usage = entry.usage + let hasTokenData = entry.resolvedTotalTokens != nil + || usage?.inputTokens != nil + || usage?.outputTokens != nil + || usage?.cacheReadTokens != nil + || usage?.cacheCreationInputTokens != nil + guard hasTokenData else { return nil } + let input = usage?.inputTokens ?? 0 + let output = usage?.outputTokens ?? 0 + let cacheRead = usage?.cacheReadTokens ?? 0 + let cacheWrite = usage?.cacheCreationInputTokens ?? 0 + if let overlay = customPricing.costUSD( + providerID: entry.provider, + model: entry.model, + inputTokens: input, + outputTokens: output, + cacheReadTokens: cacheRead, + cacheWriteTokens: cacheWrite) + { + return overlay + } + return CostUsagePricing.codexCostUSD( + model: entry.model, + inputTokens: input, + cachedInputTokens: cacheRead, + outputTokens: output, + cacheWriteInputTokens: cacheWrite, + pricingDate: entry.timestamp, + modelsDevCacheRoot: modelsDevCacheRoot) + } + + private static func add(_ lhs: Int?, _ rhs: Int?) -> Int? { + switch (lhs, rhs) { + case let (left?, right?): left + right + case let (left?, nil): left + case let (nil, right?): right + case (nil, nil): nil + } + } + + private static func add(_ lhs: Double?, _ rhs: Double?) -> Double? { + switch (lhs, rhs) { + case let (left?, right?): left + right + case let (left?, nil): left + case let (nil, right?): right + case (nil, nil): nil + } + } } diff --git a/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift b/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift index b3270bc418..0b27166ac8 100644 --- a/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift +++ b/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift @@ -149,6 +149,75 @@ struct OpenRouterPluginGoldenTests { #expect(ordinary.allSatisfy { $0.value(forHTTPHeaderField: "Authorization") == "Bearer standard-key" }) } + @Test + func `activity requests the latest completed UTC day`() async throws { + let requests = OpenRouterRequestRecorder() + let activityBody = #""" + {"data":[ + { + "date":"2026-08-17", + "model":"openai/gpt-5.6", + "prompt_tokens":10, + "completion_tokens":5, + "reasoning_tokens":2, + "requests":1, + "usage":1 + }, + { + "date":"2026-07-19", + "model":"x-ai/grok-4", + "prompt_tokens":4, + "completion_tokens":1, + "reasoning_tokens":0, + "requests":1, + "usage":1 + } + ]} + """# + let runtime = try ProviderPluginRuntime( + bundledPlugin: "openrouter", + transport: ProviderHTTPTransportHandler { request in + await requests.append(request) + let path = request.url?.path ?? "" + if path.hasSuffix("/activity") { + let date = request.url + .flatMap { URLComponents(url: $0, resolvingAgainstBaseURL: false) }? + .queryItems? + .first { $0.name == "date" }? + .value + if date == "2026-08-18" { + return try Self.response( + request, + body: #"{"error":{"message":"Date must be within the last 30 (completed) UTC days"}}"#, + statusCode: 400) + } + return try Self.response(request, body: activityBody) + } + if path.hasSuffix("/key") { + return try Self.response(request, body: #"{"data":{"limit":20,"usage":5}}"#) + } + return try Self.response(request, body: Self.defaultCreditsBody) + }) + let now = Date(timeIntervalSince1970: 1_787_079_600) // 2026-08-18T12:00:00Z; stable injected clock. + + let usage = try await runtime.fetchUsage( + secrets: [ + OpenRouterSettingsReader.envKey: "fixture-key", + OpenRouterSettingsReader.managementAPIKeyEnvironmentKey: "fixture-management-key", + ], + now: now) + let recorded = await requests.requests + let datedRequest = try #require(recorded.first { $0.url?.query != nil }) + let date = datedRequest.url + .flatMap { URLComponents(url: $0, resolvingAgainstBaseURL: false) }? + .queryItems? + .first { $0.name == "date" }? + .value + + #expect(date == "2026-08-17") + #expect(usage.costUsage?.last30DaysCostUSD == 2) + } + @Test func `server remaining drives monthly quota golden`() async throws { let usage = try await Self.fetch(keyBody: #""" diff --git a/Tests/CodexBarTests/PreferencesAdvancedPaneTests.swift b/Tests/CodexBarTests/PreferencesAdvancedPaneTests.swift new file mode 100644 index 0000000000..7afb371eb3 --- /dev/null +++ b/Tests/CodexBarTests/PreferencesAdvancedPaneTests.swift @@ -0,0 +1,45 @@ +import Testing +@testable import CodexBar + +@MainActor +struct PreferencesAdvancedPaneTests { + @Test + func `cli install status prefers successful installs over unrelated failures`() { + let status = AdvancedPane.cliInstallStatus( + installed: ["Installed: /opt/homebrew/bin"], + conflicts: [], + failures: ["No write access: /usr/local/bin"]) + + #expect(status == "Installed: /opt/homebrew/bin") + } + + @Test + func `cli install status keeps an existing earlier path conflict alongside a later successful install`() { + let status = AdvancedPane.cliInstallStatus( + installed: ["Installed: /opt/homebrew/bin"], + conflicts: ["Exists: /usr/local/bin"], + failures: []) + + #expect(status == "Installed: /opt/homebrew/bin · Exists: /usr/local/bin") + } + + @Test + func `cli install status falls back to failures when nothing installed`() { + let status = AdvancedPane.cliInstallStatus( + installed: [], + conflicts: ["Exists: /opt/homebrew/bin"], + failures: ["No write access: /usr/local/bin"]) + + #expect(status == "Exists: /opt/homebrew/bin · No write access: /usr/local/bin") + } + + @Test + func `cli install status falls back to failures when there are no successes or conflicts`() { + let status = AdvancedPane.cliInstallStatus( + installed: [], + conflicts: [], + failures: ["Failed: /opt/homebrew/bin", "No write access: /usr/local/bin"]) + + #expect(status == "Failed: /opt/homebrew/bin · No write access: /usr/local/bin") + } +} diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index a189eecbaa..9b0aef2686 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -200,12 +200,13 @@ struct ProviderArchitectureGatekeeperTests { .warp, .kilo, .mistral, .deepseek, .deepinfra, .qoder, .crof, .chutes, ]) #if os(macOS) + // Antigravity joined via the tokscale-compatible local usage readers. #expect(Set(descriptors.filter(\.tokenCost.supportsTokenSnapshot).map(\.id)) == [ - .codex, .claude, .cursor, .vertexai, .bedrock, + .codex, .claude, .cursor, .vertexai, .bedrock, .antigravity, ]) #else #expect(Set(descriptors.filter(\.tokenCost.supportsTokenSnapshot).map(\.id)) == [ - .codex, .claude, .vertexai, .bedrock, + .codex, .claude, .vertexai, .bedrock, .antigravity, ]) #endif #expect(Set(descriptors.filter { $0.cli.binaryLocator != nil }.map(\.id)) == [ @@ -908,43 +909,43 @@ struct ProviderArchitectureGatekeeperTests { reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 494, + line: 499, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 497, + line: 502, anchor: "modelProviderName: ProviderDescriptorRegistry.descriptor(for: .codex)", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 576, + line: 582, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 620, + line: 626, anchor: "let providerName = store.metadata(for: .codex).displayName", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 1557, + line: 1626, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This OpenCodex enrichment descriptor maps the canonical source back to the Codex family."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 1586, + line: 1655, anchor: "if providerID == UsageProvider.codex.rawValue {", expectedProviderIDs: ["codex"], reason: "This publication projection expands the fixed Codex provider family into its account sources."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 1603, + line: 1672, anchor: "if sourceID.hasPrefix(\"codex:\") { return .codex }", expectedProviderIDs: ["codex"], reason: "This publication projection maps stable Codex account source IDs back to their provider family."), @@ -1347,37 +1348,37 @@ struct ProviderArchitectureGatekeeperTests { reason: "This provider-owned integration passes its fixed identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 291, + line: 292, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 307, + line: 308, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 779, + line: 809, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 859, + line: 889, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 950, + line: 980, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/LocalAgentSessionScanner.swift", - line: 258, + line: 298, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), @@ -1527,19 +1528,19 @@ struct ProviderArchitectureGatekeeperTests { reason: "This logged-out-page classifier matches OpenAI's public landing-page brand token."), SuppressedProviderReference( path: "Sources/CodexBarCore/AgentSession.swift", - line: 389, + line: 412, anchor: ".appendingPathComponent(\".claude\", isDirectory: true)", expectedProviderIDs: ["claude"], reason: "The Claude transcript locator follows Claude Code's fixed default projects directory."), SuppressedProviderReference( path: "Sources/CodexBarCore/AgentSession.swift", - line: 463, + line: 486, anchor: ".appendingPathComponent(\".claude\", isDirectory: true)", expectedProviderIDs: ["claude"], reason: "The budgeted Claude transcript locator follows Claude Code's fixed default projects directory."), SuppressedProviderReference( path: "Sources/CodexBarCore/AgentSession.swift", - line: 570, + line: 593, anchor: "if value.contains(\"ide\") || value.contains(\"vscode\") || value.contains(\"cursor\") || value.contains(\"zed\") {", expectedProviderIDs: ["cursor", "zed"], reason: "This session-source classifier recognizes editor-origin strings emitted by upstream clients."), @@ -1719,7 +1720,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/IconRenderer.swift", - line: 668, + line: 691, anchor: "let twistGemini = decorations.contains(.gemini)", expectedProviderIDs: ["antigravity", "factory", "gemini", "warp"], expectedReferenceCount: 4, @@ -2223,7 +2224,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/PreferencesSpendDashboardPane.swift", - line: 496, + line: 497, anchor: ".count { $0.provider == .codex }", expectedProviderIDs: ["codex"], expectedReferenceCount: 3, @@ -2320,7 +2321,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 609, + line: 615, anchor: "(providers.contains(.codex) && settings.codexLocalSessionCostLedgerEnabled)", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2360,7 +2361,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 671, + line: 677, anchor: "if providers.contains(.codex) {", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2368,7 +2369,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 699, + line: 705, anchor: "if providers.contains(.codex) {", expectedProviderIDs: ["codex"], expectedReferenceCount: 3, @@ -2376,7 +2377,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 1630, + line: 1699, anchor: "guard input.provider == .codex,", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2464,7 +2465,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController+Animation.swift", - line: 572, + line: 573, anchor: "guard isLoading, style == .warp, let phase else {", expectedProviderIDs: ["warp"], expectedReferenceCount: 1, @@ -2472,7 +2473,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController+Animation.swift", - line: 926, + line: 928, anchor: "if provider == .kiro {", expectedProviderIDs: ["cursor", "kiro"], expectedReferenceCount: 2, @@ -2736,7 +2737,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+PlanUtilization.swift", - line: 846, + line: 903, anchor: "if provider == .claude {", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -2744,7 +2745,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+PlanUtilization.swift", - line: 861, + line: 918, anchor: "guard let identity = snapshot.identity(for: .claude) else { return nil }", expectedProviderIDs: ["claude"], expectedReferenceCount: 3, @@ -2752,7 +2753,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+PlanUtilization.swift", - line: 888, + line: 945, anchor: "key.hasPrefix(\"\\(UsageProvider.claude.rawValue):\")", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -2760,7 +2761,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+PlanUtilization.swift", - line: 1318, + line: 1383, anchor: "if ![UsageProvider.codex, .claude, .antigravity].contains(provider) {", expectedProviderIDs: ["antigravity", "claude", "codex"], expectedReferenceCount: 3, @@ -3431,7 +3432,15 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact host integration maps a provider-owned process, path, or window contract."), AllowedProviderConstruct( path: "Sources/CodexBarCore/AgentSession.swift", - line: 306, + line: 311, + anchor: "guard record.executableBasename.lowercased() == AgentSession.Provider.codex.rawValue,", + expectedProviderIDs: ["codex"], + expectedReferenceCount: 1, + expectedReferenceFingerprint: ["codex@0"], + reason: "This exact host integration recognizes only the Codex app-server bundled in ChatGPT.app."), + AllowedProviderConstruct( + path: "Sources/CodexBarCore/AgentSession.swift", + line: 328, anchor: "URL(fileURLWithPath: $0).lastPathComponent == AgentSession.Provider.claude.rawValue", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3455,7 +3464,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 584, + line: 614, anchor: "if provider == .codex {", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3463,7 +3472,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 612, + line: 642, anchor: "provider == .claude || (provider == .codex && options.shouldMergePiUsage)", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 5, @@ -3471,7 +3480,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 659, + line: 689, anchor: "options.provider == .codex || options.provider == .claude", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 2, @@ -3479,7 +3488,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 690, + line: 720, anchor: "guard provider == .codex || provider == .claude else { return nil }", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 3, @@ -3487,7 +3496,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 1237, + line: 1335, anchor: "if provider == .vertexai {", expectedProviderIDs: ["claude", "vertexai"], expectedReferenceCount: 2, @@ -3495,7 +3504,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 1593, + line: 1691, anchor: "if provider == .cursor {", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3503,15 +3512,15 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/LocalAgentSessionScanner.swift", - line: 93, + line: 129, anchor: "guard AgentPSOutputParser.provider(for: process) == .codex else { return nil }", expectedProviderIDs: ["codex"], expectedReferenceCount: 2, - expectedReferenceFingerprint: ["codex@0", "codex@5"], + expectedReferenceFingerprint: ["codex@0", "codex@4"], reason: "This exact host integration maps a provider-owned process, path, or window contract."), AllowedProviderConstruct( path: "Sources/CodexBarCore/LocalAgentSessionScanner.swift", - line: 205, + line: 245, anchor: "let claudeProcesses = processes.filter { AgentPSOutputParser.provider(for: $0) == .claude }", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3519,7 +3528,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact host integration maps a provider-owned process, path, or window contract."), AllowedProviderConstruct( path: "Sources/CodexBarCore/LocalAgentSessionScanner.swift", - line: 221, + line: 261, anchor: "let codexProcesses = processes.filter { AgentPSOutputParser.provider(for: $0) == .codex }", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 3, @@ -3527,7 +3536,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact host integration maps a provider-owned process, path, or window contract."), AllowedProviderConstruct( path: "Sources/CodexBarCore/LocalAgentSessionScanner.swift", - line: 247, + line: 287, anchor: "case .codex:", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3746,7 +3755,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift", - line: 75, + line: 80, anchor: "[\"anthropic\", \"openai\"].allSatisfy { providerID in", expectedProviderIDs: ["openai"], expectedReferenceCount: 1, diff --git a/Tests/CodexBarTests/ProviderPresentationPolicyCharacterizationTests.swift b/Tests/CodexBarTests/ProviderPresentationPolicyCharacterizationTests.swift index 77446d9eb3..93194c3ca6 100644 --- a/Tests/CodexBarTests/ProviderPresentationPolicyCharacterizationTests.swift +++ b/Tests/CodexBarTests/ProviderPresentationPolicyCharacterizationTests.swift @@ -174,6 +174,15 @@ struct ProviderPresentationPolicyCharacterizationTests { } } + @Test + func `missing secondary lane reservation is claude only`() { + for provider in UsageProvider.allCases { + let actual = ProviderDescriptorRegistry.descriptor(for: provider) + .presentation.reservesMissingSecondaryIconLane + #expect(actual == (provider == .claude), "Unexpected missing-lane policy for \(provider.rawValue)") + } + } + @Test func `credit visibility exceptions are pinned`() { let codex = ProviderDescriptorRegistry.descriptor(for: .codex).metadata diff --git a/Tests/CodexBarTests/QwenCloudProviderTests.swift b/Tests/CodexBarTests/QwenCloudProviderTests.swift index 517588fa09..9c2ca41af9 100644 --- a/Tests/CodexBarTests/QwenCloudProviderTests.swift +++ b/Tests/CodexBarTests/QwenCloudProviderTests.swift @@ -11,6 +11,44 @@ private func qwenCloudFixture(_ name: String) throws -> Data { } struct QwenCloudSettingsReaderTests { + @Test + func `missing cookie error mentions both supported browsers and their safe storage`() { + // The cookie import now probes Chrome and Brave (per + // QwenCloudProviderDescriptor.browserOrder). The recovery message + // must name both, otherwise a Brave-only user gets directed at + // Chrome and never finds the right path. + let error = QwenCloudSettingsError.missingCookie() + let message = error.errorDescription ?? "" + + #expect(message.contains("Chrome")) + #expect(message.contains("Brave")) + #expect(message.contains("Safe Storage")) + #expect(message.contains("manual Cookie header")) + #expect(message.contains("Keychain Access")) + } + + @Test + func `missing cookie error appends non-empty details`() { + let error = QwenCloudSettingsError.missingCookie( + details: "Chrome Safe Storage keychain denied") + let message = error.errorDescription ?? "" + + #expect(message.contains("Chrome")) + #expect(message.contains("Brave")) + #expect(message.contains("Chrome Safe Storage keychain denied")) + } + + @Test + func `missing cookie error omits empty details`() { + let error = QwenCloudSettingsError.missingCookie(details: "") + let message = error.errorDescription ?? "" + + #expect(message.contains("Chrome")) + #expect(message.contains("Brave")) + // No trailing whitespace from an empty details suffix. + #expect(!message.hasSuffix(" ")) + } + @Test func `cookie reads from environment`() { let cookie = QwenCloudSettingsReader.cookieHeader(environment: [ @@ -103,8 +141,12 @@ struct QwenCloudUsageSnapshotTests { #expect(metadata.sessionLabel == "5-hour") #expect(metadata.weeklyLabel == "Weekly") #if os(macOS) - #expect(metadata.browserCookieOrder == [.chrome]) - #expect(QwenCloudWebFetchStrategy.browserOrder == [.chrome]) + let expectedOrder: BrowserCookieImportOrder = [ + .chrome, + .brave, + ] + #expect(metadata.browserCookieOrder == expectedOrder) + #expect(QwenCloudWebFetchStrategy.browserOrder == expectedOrder) #else #expect(metadata.browserCookieOrder == nil) #endif diff --git a/Tests/CodexBarTests/SessionEquivalentForecastTests.swift b/Tests/CodexBarTests/SessionEquivalentForecastTests.swift index f2bcd14f09..2bd1d817c9 100644 --- a/Tests/CodexBarTests/SessionEquivalentForecastTests.swift +++ b/Tests/CodexBarTests/SessionEquivalentForecastTests.swift @@ -341,7 +341,7 @@ struct SessionEquivalentForecastTests { } @Test - func `rejects hostile dates percentages and unsorted history`() throws { + func `rejects hostile dates and percentages and normalizes unsorted history`() throws { let now = Date(timeIntervalSince1970: 1_900_000_000) let session = RateWindow( usedPercent: 20, @@ -380,15 +380,18 @@ struct SessionEquivalentForecastTests { let encodedSession = try JSONEncoder().encode(fixture.histories[0]) var sessionJSON = try #require(JSONSerialization.jsonObject(with: encodedSession) as? [String: Any]) let entriesJSON = try #require(sessionJSON["entries"] as? [[String: Any]]) + // Decoding sorts entries (PlanUtilizationSeriesHistory.init(from:)), so a reversed payload + // normalizes to the same series and yields the same estimate as the sorted fixture above. sessionJSON["entries"] = Array(entriesJSON.reversed()) let shuffledData = try JSONSerialization.data(withJSONObject: sessionJSON) let shuffledSession = try JSONDecoder().decode(PlanUtilizationSeriesHistory.self, from: shuffledData) - #expect((shuffledSession.entries.first?.capturedAt ?? .distantPast) - > (shuffledSession.entries.last?.capturedAt ?? .distantFuture)) - #expect(SessionEquivalentBurnEstimator.estimate( + #expect(shuffledSession == fixture.histories[0]) + let shuffledEstimate = try #require(SessionEquivalentBurnEstimator.estimate( histories: [shuffledSession, fixture.histories[1]], currentSessionResetsAt: fixture.currentSessionReset, - now: fixture.currentSessionReset.addingTimeInterval(-3600)) == nil) + now: fixture.currentSessionReset.addingTimeInterval(-3600))) + #expect(shuffledEstimate.sampleCount == 3) + #expect(shuffledEstimate.medianWeeklyPercentPerWindow == 6) let huge = UsagePaceText.sessionEquivalentDetail(forecast: SessionEquivalentForecast( estimatedWindowsToExhaustWeekly: .greatestFiniteMagnitude, diff --git a/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift b/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift index 83f8141ef2..0eedf2666b 100644 --- a/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift +++ b/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift @@ -210,17 +210,17 @@ struct SpendDashboardDateTruthTests { inputs: [ Self.input(id: "usd", provider: .claude, currency: "USD", cost: 2), Self.input(id: "eur", provider: .codex, currency: "EUR", cost: 3), - Self.input(id: "chf", provider: .mistral, currency: "CHF", cost: 5), + Self.input(id: "xyz", provider: .mistral, currency: "XYZ", cost: 5), ], requestedDays: 30, now: Self.now, calendar: Self.calendar, preferredCurrencyCode: "USD") - #expect(model.groups.map(\.currencyCode) == ["CHF", "USD"]) - let chf = try #require(model.groups.first(where: { $0.currencyCode == "CHF" })) + #expect(model.groups.map(\.currencyCode) == ["USD", "XYZ"]) + let xyz = try #require(model.groups.first(where: { $0.currencyCode == "XYZ" })) let usd = try #require(model.groups.first(where: { $0.currencyCode == "USD" })) - #expect(chf.totalCost == 5) + #expect(xyz.totalCost == 5) #expect(usd.providers.map(\.id).sorted() == ["eur", "usd"]) #expect(abs((usd.totalCost ?? 0) - (2 + 3 / eurRate)) < 1e-9) #expect(abs((usd.dailyPoints.map(\.cost).reduce(0, +)) - (2 + 3 / eurRate)) < 1e-9) diff --git a/Tests/CodexBarTests/SpendDashboardModelTests.swift b/Tests/CodexBarTests/SpendDashboardModelTests.swift index 6056331e6f..69b0986d75 100644 --- a/Tests/CodexBarTests/SpendDashboardModelTests.swift +++ b/Tests/CodexBarTests/SpendDashboardModelTests.swift @@ -81,6 +81,8 @@ struct SpendDashboardModelTests { .opencodego, .openrouter, .xai, + // Antigravity joined via the tokscale-compatible local usage readers. + .antigravity, ]) } diff --git a/Tests/CodexBarTests/SpendDashboardPublicationTests.swift b/Tests/CodexBarTests/SpendDashboardPublicationTests.swift index 6fddd23f4b..660f990202 100644 --- a/Tests/CodexBarTests/SpendDashboardPublicationTests.swift +++ b/Tests/CodexBarTests/SpendDashboardPublicationTests.swift @@ -24,6 +24,20 @@ struct SpendDashboardPublicationTests { settings: settings, startupBehavior: .testing, environmentBase: [:]) + // Provider-specific by design: claude stays enabled so ownership fingerprints cover an + // independent provider, but its refresh is pinned to one confirmed-empty publication so the + // source-revision baseline cannot depend on live network behavior or repeat refreshes. + // The publication is seeded before the first configuration snapshot, and the override only + // fires once, so every recompute in this test observes the same `claude:empty:1` revision. + var claudeSpendSnapshotPinned = false + store._test_tokenUsageRefreshOverride = { provider, _ in + guard provider == .claude, !claudeSpendSnapshotPinned else { return } + claudeSpendSnapshotPinned = true + store._setSpendDashboardTokenSnapshotForTesting(nil, for: .claude) + } + defer { store._test_tokenUsageRefreshOverride = nil } + store._setSpendDashboardTokenSnapshotForTesting(nil, for: .claude) + claudeSpendSnapshotPinned = true let initial = SpendDashboardSource.configuration(settings: settings, store: store) store.startSharedSpendDashboardPublication() defer { store.stopSharedSpendDashboardPublication() } @@ -57,6 +71,54 @@ struct SpendDashboardPublicationTests { rebucketed.menuOwnershipFingerprint) } + @Test + func `synchronizes independent snapshot publications`() async { + let settings = testSettingsStore(suiteName: "SpendDashboardPublicationTests-independent-sync") + settings.costUsageEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .claude) + } + settings.costUsageBucketTimeZoneIdentifier = "UTC" + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + + // Seed before observation starts so the post-start publication below has a distinct + // publication revision. Codex stays disabled: the regular publisher must not mask the + // independent synchronization under test. + store._setSpendDashboardTokenSnapshotForTesting(nil, for: .claude) + store.startSharedSpendDashboardPublication() + defer { store.stopSharedSpendDashboardPublication() } + let seeded = SpendDashboardSource.configuration(settings: settings, store: store) + await Self.waitUntil { + store.spendDashboardPublication.configuration == seeded + } + + let refreshedSnapshot = Self.input(id: "claude", provider: .claude, cost: 2.5).snapshot + store._setSpendDashboardTokenSnapshotForTesting(refreshedSnapshot, for: .claude) + + // The independent publication boundary must schedule the shared-dashboard sync itself; + // removing that call leaves this assertion green only if the regular publisher is used. + #expect(store._test_hasPendingSpendDashboardTokenPublicationSync) + + let synchronized = SpendDashboardSource.configuration(settings: settings, store: store) + #expect(synchronized != seeded) + await Self.waitUntil { + store.spendDashboardPublication.configuration == synchronized && + store.spendDashboardPublication.inputs.contains { $0.id == "claude" } + } + + #expect(store.spendDashboardPublication.inputs.first { $0.id == "claude" }? + .snapshot.last30DaysCostUSD == 2.5) + } + @Test func `shared publication starts and stops in-flight Codex dashboard catch-up`() async throws { let settings = testSettingsStore(suiteName: "SpendDashboardPublicationTests-codex-catch-up") @@ -739,11 +801,14 @@ struct SpendDashboardPublicationTests { } private static func waitUntil(_ condition: @MainActor () -> Bool) async { - for _ in 0..<1000 { + // Loaded macOS CI can stall MainActor startup long enough for a seeded confirmed-empty + // snapshot to age out; wait well past that boundary before treating publication as absent. + let deadline = Date().addingTimeInterval(30) + while Date() < deadline { if condition() { return } - await Task.yield() + try? await Task.sleep(for: .milliseconds(2)) } Issue.record("Timed out waiting for Spend Dashboard publication") } diff --git a/Tests/CodexBarTests/UsageFormatterTests.swift b/Tests/CodexBarTests/UsageFormatterTests.swift index bf7792d7dc..a64529b175 100644 --- a/Tests/CodexBarTests/UsageFormatterTests.swift +++ b/Tests/CodexBarTests/UsageFormatterTests.swift @@ -523,12 +523,18 @@ struct UsageFormatterTests { #expect(explicitCZK.contains("CZK")) #expect(explicitCZK.contains(".")) - #expect(exchange.convert(amount: 10.0, from: "CHF", to: "USD") == nil) + // CHF is supported: conversion through the USD pivot works both ways. + let chfRate = exchange.rate(for: "CHF") ?? 0.80 + #expect(abs((exchange.convert(usdAmount: 10.0, to: "CHF") ?? 0) - 10.0 * chfRate) < epsilon) + #expect(abs((exchange.convert(amount: 10.0, from: "CHF", to: "USD") ?? 0) - 10.0 / chfRate) < epsilon) + + // An unsupported provider currency stays unconverted and keeps its own code. + #expect(exchange.convert(amount: 10.0, from: "XYZ", to: "USD") == nil) let unavailable = UsageFormatter.convertedCostString( 10.0, preferredCurrency: "USD", - providerCurrency: "CHF") - #expect(unavailable.contains("CHF")) + providerCurrency: "XYZ") + #expect(unavailable.contains("XYZ")) #expect(!unavailable.contains("$")) } @@ -537,7 +543,9 @@ struct UsageFormatterTests { #expect(!CurrencyExchange.requiresLiveRates(preferredCurrencyCode: "USD")) #expect(!CurrencyExchange.requiresLiveRates(preferredCurrencyCode: " usd ")) #expect(!CurrencyExchange.requiresLiveRates(preferredCurrencyCode: "auto")) - #expect(!CurrencyExchange.requiresLiveRates(preferredCurrencyCode: "CHF")) + // Unsupported codes never trigger live-rate fetches. + #expect(!CurrencyExchange.requiresLiveRates(preferredCurrencyCode: "XYZ")) + #expect(CurrencyExchange.requiresLiveRates(preferredCurrencyCode: "CHF")) #expect(CurrencyExchange.requiresLiveRates(preferredCurrencyCode: "GBP")) #expect(CurrencyExchange.requiresLiveRates(preferredCurrencyCode: " eur ")) #expect(CurrencyExchange.requiresLiveRates(preferredCurrencyCode: "KRW")) diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationCodexMergeTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationCodexMergeTests.swift new file mode 100644 index 0000000000..0562b631f0 --- /dev/null +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationCodexMergeTests.swift @@ -0,0 +1,220 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct UsageStorePlanUtilizationCodexMergeTests { + @MainActor + @Test + func `codex materialize leaves canonical-only history untouched`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let snapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .codex, email: "alice@example.com") + let canonicalKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting( + provider: .codex, + snapshot: snapshot)) + let hourStart = Date(timeIntervalSince1970: 1_699_999_200) + let session = planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: hourStart.addingTimeInterval(3600), usedPercent: 40), + planEntry(at: hourStart, usedPercent: 10), + ]) + let weekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: hourStart.addingTimeInterval(7200), usedPercent: 15), + ]) + // Direct assignment keeps this unsorted; a self-merge would reorder by windowMinutes. + let originalHistories = [weekly, session] + let originalBuckets = PlanUtilizationHistoryBuckets( + preferredAccountKey: canonicalKey, + unscoped: [], + accounts: [ + canonicalKey: originalHistories, + ]) + store.planUtilizationHistory[.codex] = originalBuckets + store._setSnapshotForTesting(snapshot, provider: .codex) + + let revisionBefore = store.planUtilizationHistoryRevision + let history = store.planUtilizationHistory(for: .codex) + let buckets = try #require(store.planUtilizationHistory[.codex]) + + #expect(history == originalHistories) + #expect(buckets == originalBuckets) + #expect(buckets.accounts[canonicalKey] == originalHistories) + #expect(buckets.accounts.keys.sorted() == [canonicalKey]) + #expect(buckets.unscoped.isEmpty) + #expect(store.planUtilizationHistoryRevision == revisionBefore) + } + + @MainActor + @Test + func `codex materialize merge matches reference for overlapping hours out of order entries and distinct series`() + throws + { + let store = UsageStorePlanUtilizationTests.makeStore() + let snapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .codex, email: "alice@example.com") + let canonicalKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting( + provider: .codex, + snapshot: snapshot)) + let legacyEmailHash = UsageStore._codexLegacyPlanUtilizationEmailHashKeyForTesting( + normalizedEmail: "alice@example.com") + let hourStart = Date(timeIntervalSince1970: 1_699_999_200) + + let canonicalSession = planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: hourStart.addingTimeInterval(3 * 3600), usedPercent: 40), + planEntry(at: hourStart.addingTimeInterval(5 * 60), usedPercent: 10), + ]) + let canonicalWeekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: hourStart, usedPercent: 15), + ]) + let legacySession = planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: hourStart.addingTimeInterval(3600), usedPercent: 20), + planEntry(at: hourStart.addingTimeInterval(25 * 60), usedPercent: 35), + ]) + let legacyWeekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: hourStart.addingTimeInterval(3600), usedPercent: 25), + ]) + let unscopedSession = planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: hourStart.addingTimeInterval(2 * 3600), usedPercent: 30), + ]) + + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets( + unscoped: [unscopedSession], + accounts: [ + canonicalKey: [canonicalWeekly, canonicalSession], + legacyEmailHash: [legacySession, legacyWeekly], + ]) + store._setSnapshotForTesting(snapshot, provider: .codex) + + let history = store.planUtilizationHistory(for: .codex) + let buckets = try #require(store.planUtilizationHistory[.codex]) + + let expectedSession = planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: hourStart.addingTimeInterval(25 * 60), usedPercent: 35), + planEntry(at: hourStart.addingTimeInterval(3600), usedPercent: 20), + planEntry(at: hourStart.addingTimeInterval(2 * 3600), usedPercent: 30), + planEntry(at: hourStart.addingTimeInterval(3 * 3600), usedPercent: 40), + ]) + let expectedWeekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: hourStart, usedPercent: 15), + planEntry(at: hourStart.addingTimeInterval(3600), usedPercent: 25), + ]) + let expectedHistories = [expectedSession, expectedWeekly] + + #expect(history == expectedHistories) + #expect(buckets.accounts[canonicalKey] == expectedHistories) + #expect(buckets.accounts[legacyEmailHash] == nil) + #expect(buckets.unscoped.isEmpty) + #expect(findSeries(history, name: .session, windowMinutes: 300)?.entries.map(\.usedPercent) == [ + 35, 20, 30, 40, + ]) + #expect(findSeries(history, name: .weekly, windowMinutes: 10080)?.entries.map(\.usedPercent) == [15, 25]) + } + + @MainActor + @Test + func `codex materialize merge matches reference when retention trimming is exercised`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let snapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .codex, email: "alice@example.com") + let canonicalKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting( + provider: .codex, + snapshot: snapshot)) + let legacyEmailHash = UsageStore._codexLegacyPlanUtilizationEmailHashKeyForTesting( + normalizedEmail: "alice@example.com") + let hourStart = Date(timeIntervalSince1970: 1_699_999_200) + let maxSamples = UsageStore._planUtilizationMaxSamplesForTesting + let overflow = 12 + let totalSessionEntries = maxSamples + overflow + let legacySessionEntries = (0.. CodexBar + + 0.54.1 + Sun, 23 Aug 2026 03:45:07 -0700 + https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml + 128 + 0.54.1 + 14.0 + CodexBar 0.54.1 +

Highlights

+
    +
  • Codex CLI 0.149.0 compatibility: the usage probe works again after the CLI removed the untrusted approval value (#3115).
  • +
  • Layout editor drag-and-drop fixed: chip reordering and the trash drop zone are reachable again, and the trash zone doubles as a click target (#3121).
  • +
  • Faster relaunches: the Codex priority-turn scan cursor persists across restarts — no more re-scanning the whole trace database on every launch (#3130).
  • +
  • Faster spend dashboard: provider baselines and Codex multi-account scans load in parallel, cutting cold opens to roughly the slowest single provider (#3105).
  • +
  • Grok Bot on the Cursor card: weekly included Bot usage appears as a fourth bar next to Total / Cursor / Third Party (#3127).
  • +
+

Fixed

+
    +
  • Fixed the Codex CLI usage probe against Codex CLI 0.149.0: the removed untrusted approval value is replaced by never on both the app-server and isolated status launches, keeping the read-only sandbox (#3115, #3118). Thanks @kiranmagic7!
  • +
  • Fixed menu bar layout editor drag-and-drop: chips are draggable views instead of Buttons (whose gesture recognizer swallowed the drag), so reordering and the trash zone work again — and the trash zone now also removes the selected token on click (#3121). Thanks @J2TeamNNL!
  • +
  • Fixed Codex profile-home account switches briefly showing another profile's token counts, costs, usage chart, top model, and cost history while the selected profile loads (#3125).
  • +
  • Fixed the mainland Alibaba Personal/Solo Token Plan always reporting "login required": the console shell now serves its sec_token to CodexBar's fetch and the upper-case SEC_TOKEN shape is parsed (#2891, #3098). Thanks @LeoLin990405!
  • +
  • Alibaba: retry the Personal usage gateway's transient empty-Success responses instead of surfacing a parse error (#3128). Thanks @LeoLin990405!
  • +
  • Fixed long agent session names stretching the menu: session rows now truncate inside the menu's width with the full label in a tooltip (#3096). Thanks @KaranocaVe!
  • +
+

Performance

+
    +
  • Codex: persist the priority-turn trace-database scan cursor across relaunches so a restart resumes incrementally instead of re-scanning the whole logs_2.sqlite (minutes of full-core CPU on large trace databases); existing cost caches are adopted on upgrade, not rebuilt (#3130). Thanks @olddonkey!
  • +
  • Spend dashboard: load provider baselines and Codex multi-account scans in parallel and memoize currency conversion and calendar buckets, cutting cold opens from multiple seconds to roughly the slowest single provider (#3105). Thanks @Yuxin-Qiao!
  • +
+

Providers

+
    +
  • Cursor: show Grok Bot weekly included usage as a fourth card bar sourced from the dashboard's sand-usage endpoint, best-effort and hidden on accounts without a Bot allowance or on legacy request plans (#3127). Thanks @kvarga!
  • +
  • Codex: show Business/Enterprise individual monthly credit used vs cap on stacked multi-account cards instead of "Limits not available" (#3112). Thanks @sf-jin-ku!
  • +
  • Claude: migrate email-keyed claude-swap iCloud snapshots to their slot-keyed records and delete the leftover email-keyed predecessors, so other Macs stop showing duplicate fleet cards (#3111). Thanks @sf-jin-ku!
  • +
  • Claude: distinguish claude-swap accounts that share an email with the workspace name or slot, and honor a user-chosen display alias (#3082). Thanks @sf-jin-ku!
  • +
  • Kiro: show overage credits spent against their cap, plus accrued charges against the overage budget — reading the plan and overage ceilings from the same GetUsageLimits service the CLI itself calls, with a CLI-report fallback (#3083). Thanks @sf-jin-ku!
  • +
  • Command Code: recognize the repriced Pro tier (individual-pro-v1, $80/mo in credits) instead of failing with an unknown-plan error (#3116). Thanks @sebastianmarines!
  • +
  • z.ai: show the BigModel CN pay-as-you-go account balance inside Quota details, best-effort and CN-only (#3109). Thanks @RunhuaHuang!
  • +
+

Localization

+
    +
  • Simplified Chinese now labels quota windows by their actual duration ("5 小时" instead of a generic session label), keeping the generic wording for conversations (#3069, #3070). Thanks @YunyueLi!
  • +
+

View full changelog

+]]>
+ +
0.54.0 Thu, 20 Aug 2026 06:51:02 -0700 @@ -127,29 +175,6 @@ ]]> - - 0.52.0 - Mon, 17 Aug 2026 04:24:21 -0700 - https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml - 124 - 0.52.0 - 14.0 - CodexBar 0.52.0 -

Added

-
    -
  • Usage & Spend: aggregate per-project spend into ranked, window-scoped rows and carry project/session breakdowns through the cached Codex prefill so the dashboard and the menu chart agree on project data (#2984). Thanks @Yuxin-Qiao!
  • -
  • Usage & Spend: add a Projects panel to the settings pane and let the Models and Projects lists expand beyond the top eight rows (#2985). Thanks @Yuxin-Qiao!
  • -
-

Fixed

-
    -
  • Mission Control: remove the invisible lifecycle keepalive window entirely; the rebuilt Settings window no longer needs it, eliminating the floating all-Spaces WindowServer footprint that dismissed Mission Control on macOS 27 (#2955, #2997, #3003). Thanks @phuchong89 for the diagnostics!
  • -
  • Cost history chart: localize the Projects, Conversations, session, and standard/fast mode labels that previously stayed English for all 23 app languages (#2983). Thanks @Yuxin-Qiao!
  • -
  • Grok: show SuperGrok Heavy from the CLI settings subscription_tier_display instead of labeling every OIDC login as SuperGrok (#2991). Thanks @olddonkey!
  • -
-

View full changelog

-]]>
- -
0.14.0 Thu, 25 Dec 2025 03:56:15 +0100 diff --git a/docs/antigravity.md b/docs/antigravity.md index 2db0db98c2..9dac09e2bc 100644 --- a/docs/antigravity.md +++ b/docs/antigravity.md @@ -18,7 +18,8 @@ signal and never enables or falls back to Antigravity automatically. To use the `agy` CLI source without keeping the desktop app open, install the CLI first (`brew install --cask antigravity-cli`; use `ANTIGRAVITY_CLI_PATH` when it is not on PATH), then run `agy` once and sign in. CodexBar keeps the signed-in `agy` local HTTPS server alive briefly -after each refresh and stops it when idle. +after each refresh and stops it when idle, or reuses a signed-in `agy` you already have running +without taking ownership of that process. Antigravity supports four usage data sources: @@ -159,10 +160,11 @@ The fallback can return quota without the account email or plan fields from `Get Differences from the desktop local probe: - The CLI HTTPS endpoint does **not** require `X-Codeium-Csrf-Token`. -- Before a one-shot CLI invocation launches `agy`, CodexBar spends at most two seconds looking for an already-running, - same-user `agy` at the selected binary path and reuses its tokenless local HTTPS endpoint when it returns parseable - usage for the selected account. Long-lived app/server refreshes keep using CodexBar's managed session, and - CodexBar-owned pids are excluded from external reuse so probe/idle lifecycle accounting stays balanced. +- Before launching `agy`, both menu-bar refreshes and one-shot CLI invocations spend at most two seconds looking for + an already-running, same-user `agy` at the selected binary path and reuse its tokenless local HTTPS endpoint when it + returns parseable usage for the selected account. CodexBar-owned pids are excluded from external reuse so managed + probe/idle lifecycle accounting stays balanced; if no eligible external server answers, CodexBar uses its managed + session as before. - Readiness is endpoint-based: CodexBar retries until one of the quota endpoints parses, because fresh `agy` processes can bind a port before the quota service is initialized. - App runtime uses a bounded warm session: `agy` is kept alive briefly after a refresh, then stopped on idle. CLI runtime diff --git a/docs/grok.md b/docs/grok.md index 348d1c8186..7c24d28a94 100644 --- a/docs/grok.md +++ b/docs/grok.md @@ -55,7 +55,7 @@ The grok.com billing gRPC-web endpoint remains a best-effort fallback. `Accept: application/json`. - Reads `config.creditUsagePercent`, falling back to `onDemandUsed.val / onDemandCap.val * 100`. A parseable current period - without either value represents zero usage. The reset timestamp comes from + without either value represents unknown usage. The reset timestamp comes from `config.currentPeriod.end`, then `config.billingPeriodEnd`. - Plan name does not come from the credits payload. After a successful auth-file or SuperGrok OAuth web billing result (CLI-proxy) or the team diff --git a/docs/qwen-cloud-proof/README.md b/docs/qwen-cloud-proof/README.md new file mode 100644 index 0000000000..85e431d428 --- /dev/null +++ b/docs/qwen-cloud-proof/README.md @@ -0,0 +1,100 @@ +# Qwen Cloud browser-cookie import — real behavior proof + +Captured 2026-08-22 against the live Qwen Cloud API via the modified CodexBar +binary (`/Applications/CodexBar-QwenFix.app`, debug build of branch +`diagnose/qwen-cloud-cookie-error`). + +## Before the fix (commit `529cc6c24` "Keep Qwen imports Chrome-only") + +```text +$ CodexBarCLI usage --provider qwen-cloud --format text --no-color +[error] No Qwen Cloud session cookies found in browsers. Sign in to Qwen Cloud + in Chrome, allow CodexBar to access Chrome Safe Storage in Keychain + Access, or paste a manual Cookie header. +``` + +The user is signed in to Qwen Cloud in **Brave** (not Chrome). The cookie +import never probed Brave, so the importer returned no session even though +`login_qwencloud_ticket` was present in +`~/Library/Application Support/BraveSoftware/Brave-Browser/Default/Cookies`. + +## After the fix (branch `diagnose/qwen-cloud-cookie-error`, PR #3147) + +`browserOrder` is now `[.chrome, .brave]` (per `AGENTS.md` L48 — "default +Chrome-only when possible to avoid other browser prompts; override via +browser list when needed"). Once the user granted macOS Keychain access to +the modified binary, the same CLI call returns real usage data: + +```text +$ CodexBarCLI usage --provider qwen-cloud --format text --no-color +== Qwen Cloud (web) == +Weekly: 20% left [==----------] +Resets in 2d 2h +Plan: Pro +``` + +```json +{ + "source": "web", + "provider": "qwencloud", + "usage": { + "identity": { + "providerID": "qwencloud", + "loginMethod": "Pro" + }, + "secondary": { + "usedPercent": 79.97, + "windowMinutes": 10080, + "resetsAt": "2026-08-25T01:21:00Z", + "resetDescription": "31,987.03 / 40,000 credits used" + }, + "updatedAt": "2026-08-22T23:11:11Z" + } +} +``` + +## How the proof was obtained + +1. `swift build` from the worktree at `/tmp/codexbar-main` succeeded. +2. `swift test --filter QwenCloudProviderTests` — 32/32 tests passed across 7 suites. +3. `./Scripts/package_app.sh debug` — packaged `CodexBar.app` (ad-hoc signed). +4. Installed to `/Applications/CodexBar-QwenFix.app` (no quarantine). +5. First refresh via the GUI menu bar — the macOS Keychain dialog appeared for `Brave Safe Storage`; "Always Allow" granted permanent ACL access. +6. CLI runs thereafter (with `CODEXBAR_ALLOW_BROWSER_COOKIE_IMPORT=1`) return the same real usage data shown above. + +## What changed in the code + +```diff +--- a/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudProviderDescriptor.swift ++++ b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudProviderDescriptor.swift +- let browserOrder: BrowserCookieImportOrder = [.chrome] ++ // Default the import to Chrome + Brave. The full 7-browser list ++ // (chromeBeta, edge, arc, firefox, safari) was deliberately trimmed to ++ // avoid unsolicited Keychain / browser-store access prompts on automatic ++ // refreshes — the repository's prompt-avoidance policy. Brave is kept ++ // because it shares the same Chromium Safe Storage format and many ++ // CodexBar users authenticate Qwen Cloud in Brave. ++ let browserOrder: BrowserCookieImportOrder = [ ++ .chrome, ++ .brave, ++ ] +``` + +```diff +--- a/Tests/CodexBarTests/QwenCloudProviderTests.swift ++++ b/Tests/CodexBarTests/QwenCloudProviderTests.swift +- #expect(metadata.browserCookieOrder == [.chrome]) +- #expect(QwenCloudWebFetchStrategy.browserOrder == [.chrome]) ++ let expectedOrder: BrowserCookieImportOrder = [ ++ .chrome, ++ .brave, ++ ] ++ #expect(metadata.browserCookieOrder == expectedOrder) ++ #expect(QwenCloudWebFetchStrategy.browserOrder == expectedOrder) +``` + +## Redaction notes + +- `loginMethod: "Pro"` is the plan tier only — no account identifier, no email, no user ID, no cookie values, no Keychain contents are reproduced here. +- The 79.97% weekly figure is a snapshot, not a real customer number. +- The cookie value column in the Brave SQLite cookie store is empty (the value is stored in the `encrypted_value` BLOB and only readable after decrypting with the user's Brave Safe Storage key); no decrypted cookie contents appear anywhere in this proof. diff --git a/docs/screenshots/cli-install-nonwritable-conflict-after.png b/docs/screenshots/cli-install-nonwritable-conflict-after.png new file mode 100644 index 0000000000..06a9d1cc15 Binary files /dev/null and b/docs/screenshots/cli-install-nonwritable-conflict-after.png differ diff --git a/docs/screenshots/cli-install-nonwritable-conflict-before.png b/docs/screenshots/cli-install-nonwritable-conflict-before.png new file mode 100644 index 0000000000..6af410a428 Binary files /dev/null and b/docs/screenshots/cli-install-nonwritable-conflict-before.png differ diff --git a/docs/screenshots/cli-install-write-failure-after.png b/docs/screenshots/cli-install-write-failure-after.png new file mode 100644 index 0000000000..331d22b38b Binary files /dev/null and b/docs/screenshots/cli-install-write-failure-after.png differ diff --git a/docs/screenshots/cli-install-write-failure-before.png b/docs/screenshots/cli-install-write-failure-before.png new file mode 100644 index 0000000000..0bc18eab62 Binary files /dev/null and b/docs/screenshots/cli-install-write-failure-before.png differ diff --git a/docs/screenshots/codex-single-quota-icon-after.png b/docs/screenshots/codex-single-quota-icon-after.png new file mode 100644 index 0000000000..0118c1fa91 Binary files /dev/null and b/docs/screenshots/codex-single-quota-icon-after.png differ diff --git a/docs/screenshots/codex-single-quota-icon-before.png b/docs/screenshots/codex-single-quota-icon-before.png new file mode 100644 index 0000000000..f921b09297 Binary files /dev/null and b/docs/screenshots/codex-single-quota-icon-before.png differ diff --git a/docs/screenshots/codex-single-quota-icon-proof.md b/docs/screenshots/codex-single-quota-icon-proof.md new file mode 100644 index 0000000000..d0ad401439 --- /dev/null +++ b/docs/screenshots/codex-single-quota-icon-proof.md @@ -0,0 +1,24 @@ +# Codex single-quota icon proof + +These screenshots use a fixed synthetic input: `46% remaining`, with no secondary quota and no credits. +They contain only pixels from `IconRenderer` plus the generic labels shown in the cards. No live provider, +account, Keychain, desktop, username, email, or filesystem data is read or displayed. + +| Before | After | +| --- | --- | +| ![Before: 46% occupies the upper lane while the unavailable lower lane remains](codex-single-quota-icon-before.png) | ![After: 46% occupies one prominent meter](codex-single-quota-icon-after.png) | + +The before image was rendered from upstream commit `27c7f334e3c46c96ff8c063afbe0c7944ba5e0b7` with +`style: .codex`. The after image was rendered from this branch with `style: .combined` and +`quotaLayoutPolicy: .provider(.codex)`, matching the merged-menu dispatch path. The old renderer did not +accept an explicit quota-layout policy; with critters hidden, its relevant single-quota geometry was the same +for `.codex` and `.combined`. + +Generate the after proof with: + +```sh +CODEXBAR_ICON_SCREENSHOT_DIR=docs/screenshots \ + swift test --filter IconRendererScreenshotRenderTests +``` + +The opt-in screenshot test is skipped during normal test runs.