From c82dab5a6d683729a0da8212af70b3e1df239b50 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sat, 25 Jul 2026 16:58:17 -0700 Subject: [PATCH 001/183] Add CLIProxyAPI cost attribution --- CHANGELOG.md | 3 + .../CodexBar/CostHistoryChartMenuView.swift | 27 +- .../PreferencesSpendDashboardPane.swift | 161 +++++++- .../CodexBar/SpendDashboardController.swift | 10 + Sources/CodexBar/SpendDashboardModel.swift | 40 +- Sources/CodexBar/UsageStore+CLIProxyAPI.swift | 25 ++ Sources/CodexBar/UsageStore.swift | 3 + .../CLIProxyAPIAttributionResolver.swift | 356 ++++++++++++++++ .../CLIProxyAPIUsageTelemetry.swift | 378 +++++++++++++++++ Sources/CodexBarCore/CostUsageFetcher.swift | 187 +++++++-- Sources/CodexBarCore/CostUsageModels.swift | 148 ++++++- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageCache.swift | 2 +- .../CostUsagePricing+ModelProvider.swift | 42 ++ .../Vendored/CostUsage/CostUsagePricing.swift | 41 ++ .../CostUsageScanner+CacheHelpers.swift | 11 +- .../CostUsage/CostUsageScanner+Claude.swift | 346 +++++++++++++--- .../Vendored/CostUsage/CostUsageScanner.swift | 13 + .../CLIProxyAPIAttributionResolverTests.swift | 388 ++++++++++++++++++ Tests/CodexBarTests/CostUsageCacheTests.swift | 4 +- .../CostUsageDailyReportMergeTests.swift | 50 +++ .../CodexBarTests/CostUsageFetcherTests.swift | 354 ++++++++++++++++ .../SpendDashboardControllerTests.swift | 16 +- .../SpendDashboardProxyAttributionTests.swift | 84 ++++ .../SpendDashboardTokenProvenanceTests.swift | 6 +- 25 files changed, 2572 insertions(+), 125 deletions(-) create mode 100644 Sources/CodexBar/UsageStore+CLIProxyAPI.swift create mode 100644 Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift create mode 100644 Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift create mode 100644 Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ModelProvider.swift create mode 100644 Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift create mode 100644 Tests/CodexBarTests/SpendDashboardProxyAttributionTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index f7f2272d59..21bbe76318 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ## 0.45.3 — Unreleased ### Fixed +- Usage & Spend: keep CLIProxyAPI-routed Claude Code sessions in the correct upstream total using request logs + and active provider credentials, with sanitized per-request telemetry when available, instead of splitting + unchanged usage into separate proxy and unknown-route rows. ## 0.45.2 — 2026-07-19 diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index 210d0a3f52..8bcc5a0c4c 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -2,6 +2,15 @@ import Charts import CodexBarCore import SwiftUI +private func claudeCodeModelProviderText(_ provider: CostUsageAttribution.ModelProvider) -> String { + switch provider { + case .openAI: "OpenAI model via Claude Code" + case .anthropic: "Anthropic model via Claude Code" + case .google: "Google model via Claude Code" + case .unknown: "Unknown model provider via Claude Code" + } +} + @MainActor struct CostHistoryChartMenuView: View { typealias DailyEntry = CostUsageDailyReport.Entry @@ -573,7 +582,7 @@ struct CostHistoryChartMenuView: View { } private static func hasModeSubtitle(_ item: CostUsageDailyReport.ModelBreakdown) -> Bool { - item.standardCostUSD != nil || item.priorityCostUSD != nil + item.attribution != nil || item.standardCostUSD != nil || item.priorityCostUSD != nil } private static func detailRowsViewportHeight(rowCount: Int, rowHeight: CGFloat) -> CGFloat { @@ -839,6 +848,19 @@ struct CostHistoryChartMenuView: View { private func modelBreakdownModeSubtitle(_ item: CostUsageDailyReport.ModelBreakdown) -> String? { var parts: [String] = [] + if let attribution = item.attribution { + switch attribution.route { + case .cliProxyAPI: + let route = if let upstream = attribution.upstream { + "\(upstream.displayName) · CLIProxyAPI via Claude Code" + } else { + "CLIProxyAPI via Claude Code" + } + parts.append(route) + case .unknown: + parts.append(claudeCodeModelProviderText(attribution.modelProvider)) + } + } if let standardCost = item.standardCostUSD { var standardPart = "Std \(self.costString(standardCost))" if let standardTokens = item.standardTokens { @@ -897,6 +919,7 @@ extension CostHistoryChartMenuView { struct VisibleModelBreakdownFingerprint: Equatable { let modelName: String + let attribution: CostUsageAttribution? let costBitPattern: UInt64? let totalTokens: Int? let standardCostBitPattern: UInt64? @@ -976,6 +999,7 @@ extension CostHistoryChartMenuView { models: session.modelBreakdowns.map { item in VisibleModelBreakdownFingerprint( modelName: item.modelName, + attribution: item.attribution, costBitPattern: item.costUSD.map(\.bitPattern), totalTokens: item.totalTokens, standardCostBitPattern: item.standardCostUSD.map(\.bitPattern), @@ -995,6 +1019,7 @@ extension CostHistoryChartMenuView { modelBreakdowns: self.orderedBreakdownItems(entry.modelBreakdowns ?? []).map { item in VisibleModelBreakdownFingerprint( modelName: item.modelName, + attribution: item.attribution, costBitPattern: item.costUSD.map(\.bitPattern), totalTokens: item.totalTokens, standardCostBitPattern: item.standardCostUSD.map(\.bitPattern), diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index 83e7f31038..a91ffc5b2a 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -27,6 +27,28 @@ func spendDashboardCoverageText(covered: Int, requested: Int) -> String { "\(L("Coverage")): \(codexBarLocalizedInteger(covered)) / \(codexBarLocalizedInteger(requested))" } +func spendDashboardModelSourceText( + providerName: String, + attribution: CostUsageAttribution?) -> String +{ + guard let attribution else { return providerName } + switch attribution.route { + case .cliProxyAPI: + if let upstream = attribution.upstream { + return "\(upstream.displayName) · CLIProxyAPI via Claude Code" + } + return "CLIProxyAPI via Claude Code" + case .unknown: + let modelProvider = switch attribution.modelProvider { + case .openAI: "OpenAI model" + case .anthropic: "Anthropic model" + case .google: "Google model" + case .unknown: "Unknown model provider" + } + return "\(providerName) · \(modelProvider) via Claude Code" + } +} + enum SpendDashboardModelHistoryPresentation: Equatable { case unavailable case empty @@ -48,6 +70,11 @@ struct SpendDashboardPane: View { @Bindable var settings: SettingsStore @Bindable var store: UsageStore @State private var controller: SpendDashboardController + @State private var cliProxyAPIBaseURL = CLIProxyAPIConnectionSettings.defaultBaseURL + @State private var cliProxyAPIManagementKey = "" + @State private var cliProxyAPIHasSavedConfiguration = false + @State private var cliProxyAPIStatus: String? + @State private var cliProxyAPIIsSaving = false init(settings: SettingsStore, store: UsageStore) { self.settings = settings @@ -62,6 +89,7 @@ struct SpendDashboardPane: View { VStack(alignment: .leading, spacing: 18) { self.header self.content + self.cliProxyAPISetup self.provenance self.shareAction } @@ -71,6 +99,7 @@ struct SpendDashboardPane: View { .onAppear { self.controller.refreshDateWindow() self.controller.update(configuration: self.configuration) + self.loadCLIProxyAPIConfiguration() } .onChange(of: self.configuration) { _, configuration in self.controller.update(configuration: configuration) @@ -173,6 +202,132 @@ struct SpendDashboardPane: View { } } + private var cliProxyAPISetup: some View { + SpendDashboardPanel { + VStack(alignment: .leading, spacing: 12) { + HStack { + Label("CLIProxyAPI attribution", systemImage: "point.3.connected.trianglepath.dotted") + .font(.headline) + Spacer() + if self.cliProxyAPIHasSavedConfiguration { + Label("Configured", systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Text( + "Connect CLIProxyAPI’s management usage queue to identify the exact upstream provider " + + "and whether it used OAuth or an API key. Enter the same plaintext " + + "remote-management secret key configured in CLIProxyAPI.") + .font(.caption) + .foregroundStyle(.secondary) + + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 10) { + GridRow { + Text("Server URL") + .foregroundStyle(.secondary) + TextField( + CLIProxyAPIConnectionSettings.defaultBaseURL, + text: self.$cliProxyAPIBaseURL) + .textFieldStyle(.roundedBorder) + } + GridRow { + Text("Management key") + .foregroundStyle(.secondary) + SecureField( + self.cliProxyAPIHasSavedConfiguration ? "Saved — enter to replace" : "Required", + text: self.$cliProxyAPIManagementKey) + .textFieldStyle(.roundedBorder) + } + } + + HStack(spacing: 8) { + Button(self.cliProxyAPIHasSavedConfiguration ? "Save & test" : "Connect & test") { + Task { + await self.saveAndTestCLIProxyAPIConfiguration() + } + } + .disabled(self.cliProxyAPIIsSaving) + + if self.cliProxyAPIHasSavedConfiguration { + Button("Remove", role: .destructive) { + self.removeCLIProxyAPIConfiguration() + } + .disabled(self.cliProxyAPIIsSaving) + } + + if self.cliProxyAPIIsSaving { + ProgressView() + .controlSize(.small) + } + if let cliProxyAPIStatus { + Text(cliProxyAPIStatus) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + + Text( + "Reading the queue removes the returned records from CLIProxyAPI. CodexBar stores a " + + "sanitized local copy for cost attribution and does not retain source, account, " + + "API-key, response-header, or failure-body fields.") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + + private func loadCLIProxyAPIConfiguration() { + guard let configuration = CLIProxyAPIConnectionSettingsStore.load() else { return } + self.cliProxyAPIBaseURL = configuration.baseURL + self.cliProxyAPIHasSavedConfiguration = true + } + + private func saveAndTestCLIProxyAPIConfiguration() async { + self.cliProxyAPIIsSaving = true + defer { self.cliProxyAPIIsSaving = false } + + let existingKey = CLIProxyAPIConnectionSettingsStore.load()?.managementKey ?? "" + let enteredKey = self.cliProxyAPIManagementKey.trimmingCharacters(in: .whitespacesAndNewlines) + let configuration = CLIProxyAPIConnectionSettings( + baseURL: self.cliProxyAPIBaseURL, + managementKey: enteredKey.isEmpty ? existingKey : enteredKey) + guard configuration.isConfigured else { + self.cliProxyAPIStatus = "Enter a valid URL and management key." + return + } + guard CLIProxyAPIConnectionSettingsStore.save(configuration) else { + self.cliProxyAPIStatus = "Could not save the management key." + return + } + + self.cliProxyAPIManagementKey = "" + self.cliProxyAPIHasSavedConfiguration = true + switch await self.store.collectCLIProxyAPIUsageNow() { + case .notConfigured: + self.cliProxyAPIStatus = "Configuration was not available." + case let .collected(count): + self.cliProxyAPIStatus = count == 0 + ? "Connected. No queued records." + : "Connected. Imported \(count) records." + self.controller.refresh() + case let .failed(message): + self.cliProxyAPIStatus = "Saved, but test failed: \(message)" + } + } + + private func removeCLIProxyAPIConfiguration() { + guard CLIProxyAPIConnectionSettingsStore.clear() else { + self.cliProxyAPIStatus = "Could not remove the saved configuration." + return + } + self.cliProxyAPIManagementKey = "" + self.cliProxyAPIHasSavedConfiguration = false + self.cliProxyAPIStatus = "Configuration removed." + } + private var shareAction: some View { HStack { Spacer() @@ -366,7 +521,11 @@ private struct SpendModelPanel: View { SpendProviderIcon(provider: row.provider) VStack(alignment: .leading, spacing: 2) { Text(row.modelName).lineLimit(1) - Text(row.providerName).font(.caption).foregroundStyle(.secondary) + Text(spendDashboardModelSourceText( + providerName: row.providerName, + attribution: row.attribution)) + .font(.caption) + .foregroundStyle(.secondary) } Spacer() Text(row.totalCost.map { diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index 6f9ec8c361..40642ff99e 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -394,6 +394,16 @@ enum SpendDashboardSource { encoder.append(entry.modelBreakdowns?.count) for breakdown in entry.modelBreakdowns ?? [] { encoder.append(breakdown.modelName) + encoder.append(breakdown.attribution?.client.rawValue ?? "") + encoder.append(breakdown.attribution?.route.rawValue ?? "") + encoder.append(breakdown.attribution?.modelProvider.rawValue ?? "") + encoder.append(breakdown.attribution?.upstream?.provider ?? "") + encoder.append(breakdown.attribution?.upstream?.authType.rawValue ?? "") + encoder.append(breakdown.attribution?.upstream?.model ?? "") + encoder.append(breakdown.attribution?.upstream?.executorType ?? "") + for evidence in breakdown.attribution?.evidence ?? [] { + encoder.append(evidence.rawValue) + } encoder.append(breakdown.totalTokens) encoder.append(breakdown.requestCount) encoder.append(breakdown.costUSD) diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index c3ed207f96..956ed3629b 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -41,9 +41,35 @@ struct SpendDashboardModel: Equatable, Sendable { let modelName: String let totalTokens: Int? let totalCost: Double? + let attribution: CostUsageAttribution? + + init( + rank: Int, + provider: UsageProvider, + providerName: String, + modelName: String, + totalTokens: Int?, + totalCost: Double?, + attribution: CostUsageAttribution? = nil) + { + self.rank = rank + self.provider = provider + self.providerName = providerName + self.modelName = modelName + self.totalTokens = totalTokens + self.totalCost = totalCost + self.attribution = attribution + } var id: String { - "\(self.provider.rawValue):\(self.modelName)" + [ + self.provider.rawValue, + self.modelName, + self.attribution?.client.rawValue ?? "", + self.attribution?.route.rawValue ?? "", + self.attribution?.upstream?.provider ?? "", + self.attribution?.upstream?.authType.rawValue ?? "", + ].joined(separator: ":") } } @@ -128,6 +154,7 @@ struct SpendDashboardModel: Equatable, Sendable { private struct ModelKey: Hashable { let provider: UsageProvider let modelName: String + let attribution: CostUsageAttribution? } private struct ModelAccumulator { @@ -285,7 +312,10 @@ struct SpendDashboardModel: Equatable, Sendable { for breakdown in breakdowns { let name = breakdown.modelName.trimmingCharacters(in: .whitespacesAndNewlines) guard !name.isEmpty else { continue } - let key = ModelKey(provider: input.provider, modelName: name) + let key = ModelKey( + provider: input.provider, + modelName: name, + attribution: breakdown.attribution) var aggregate = aggregates[key] ?? ModelAccumulator( providerName: input.modelProviderName, tokens: 0, @@ -324,7 +354,8 @@ struct SpendDashboardModel: Equatable, Sendable { providerName: value.providerName, modelName: key.modelName, totalTokens: value.sawTokens && !value.invalidTokens && !value.overflowedTokens ? value.tokens : nil, - totalCost: value.sawCost && !value.invalidCost && !value.overflowedCost ? value.cost : nil) + totalCost: value.sawCost && !value.invalidCost && !value.overflowedCost ? value.cost : nil, + attribution: key.attribution) } .sorted { lhs, rhs in switch (lhs.totalCost, rhs.totalCost) { @@ -346,7 +377,8 @@ struct SpendDashboardModel: Equatable, Sendable { providerName: row.providerName, modelName: row.modelName, totalTokens: row.totalTokens, - totalCost: row.totalCost) + totalCost: row.totalCost, + attribution: row.attribution) } return ModelSummary(rows: rows, completeness: completeness) } diff --git a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift new file mode 100644 index 0000000000..e4ff555642 --- /dev/null +++ b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift @@ -0,0 +1,25 @@ +import CodexBarCore +import Foundation + +@MainActor +extension UsageStore { + private static let cliProxyAPIUsageCollectionInterval: Duration = .seconds(30) + + func startCLIProxyAPIUsageCollector() { + self.cliProxyAPIUsageCollectorTask?.cancel() + self.cliProxyAPIUsageCollectorTask = Task.detached(priority: .utility) { + while !Task.isCancelled { + _ = await CLIProxyAPIUsageCollector.collect() + do { + try await Task.sleep(for: Self.cliProxyAPIUsageCollectionInterval) + } catch { + return + } + } + } + } + + func collectCLIProxyAPIUsageNow() async -> CLIProxyAPIUsageCollectionResult { + await CLIProxyAPIUsageCollector.collect() + } +} diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 30be9d30d7..c1ca8a92a9 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -363,6 +363,7 @@ final class UsageStore { /// Background load task; cleared on deinit and on the cancel test seam. @ObservationIgnored var planUtilizationHistoryLoadTask: Task? + @ObservationIgnored var cliProxyAPIUsageCollectorTask: Task? /// Set once after the load completes. Gates mutation paths and sync menu /// accessors so they cannot race the decode or write empty history back to disk. @ObservationIgnored var planUtilizationHistoryLoaded: Bool = false @@ -478,6 +479,7 @@ final class UsageStore { Task { await self.refresh(enrichmentMode: .automatic) } self.startTimer() self.startTokenTimer() + self.startCLIProxyAPIUsageCollector() } var iconStyle: IconStyle { @@ -856,6 +858,7 @@ final class UsageStore { self.codexPlanHistoryBackfillTask?.cancel() self.resetBoundaryRefreshTask?.cancel() self.planUtilizationHistoryLoadTask?.cancel() + self.cliProxyAPIUsageCollectorTask?.cancel() } enum SessionQuotaWindowSource: String { diff --git a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift new file mode 100644 index 0000000000..295224cd5f --- /dev/null +++ b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift @@ -0,0 +1,356 @@ +import Foundation + +struct CLIProxyAPIAttributionResolver: Sendable { + struct Observation: Sendable, Equatable { + let sessionID: String + let model: String + let timestamp: Date? + } + + struct AuthProvider: Sendable, Equatable, Hashable { + let provider: String + let authType: CostUsageAttribution.Upstream.AuthType + } + + struct TokenSignature: Sendable, Equatable { + let input: Int + let cacheRead: Int + let cacheCreate: Int + let output: Int + } + + private static let requestBodyMarker = "=== REQUEST BODY ===" + private static let responseMarkers = ["=== API RESPONSE ===", "=== RESPONSE ==="] + private static let maxLogPrefixBytes = 2 * 1024 * 1024 + private static let maximumRouteMatchDistance: TimeInterval = 60 * 60 + private static let maximumTelemetryMatchDistance: TimeInterval = 5 + + private let observationsBySessionID: [String: [Observation]] + private let usageRecords: [CLIProxyAPIUsageRecord] + private let authProviders: [AuthProvider] + private let hasConfiguredOpenAIAPIUpstream: Bool + + init( + observations: [Observation], + usageRecords: [CLIProxyAPIUsageRecord] = [], + authProviders: [AuthProvider] = [], + hasConfiguredOpenAIAPIUpstream: Bool = false) + { + self.observationsBySessionID = Dictionary(grouping: observations, by: \.sessionID) + self.usageRecords = usageRecords + self.authProviders = authProviders + self.hasConfiguredOpenAIAPIUpstream = hasConfiguredOpenAIAPIUpstream + } + + static func load( + home: URL, + cacheRoot: URL? = nil, + fileManager: FileManager = .default) -> Self + { + let observations = self.loadObservations( + logDirectory: home.appendingPathComponent("logs", isDirectory: true), + fileManager: fileManager) + return Self( + observations: observations, + usageRecords: CLIProxyAPIUsageCacheIO.load(cacheRoot: cacheRoot), + authProviders: self.loadAuthProviders(home: home, fileManager: fileManager), + hasConfiguredOpenAIAPIUpstream: self.hasConfiguredOpenAIAPIUpstream( + home: home, + fileManager: fileManager)) + } + + func attribution( + model: String, + modelProvider: CostUsageAttribution.ModelProvider, + sessionID: String?, + timestampUnixMs: Int64?, + tokens: TokenSignature?) -> CostUsageAttribution + { + let routeObservation = self.sessionObservation(sessionID: sessionID) + let telemetryObservation = self.matchingObservation( + model: model, + sessionID: sessionID, + timestampUnixMs: timestampUnixMs) + let usageRecord = telemetryObservation.flatMap { + self.matchingUsageRecord( + observation: $0, + model: model, + tokens: tokens) + } + let inventoryUpstream = usageRecord == nil + ? self.authInventoryUpstream(model: model, modelProvider: modelProvider) + : nil + let routeConfirmed = routeObservation != nil || inventoryUpstream != nil + var evidence: Set = [.modelProvider] + if routeObservation != nil || inventoryUpstream != nil { + evidence.insert(.cliProxyRequestLog) + } + if usageRecord != nil { + evidence.insert(.cliProxyUsageTelemetry) + } + if inventoryUpstream != nil { + evidence.insert(.cliProxyAuthInventory) + } + + return CostUsageAttribution( + client: .claudeCode, + route: routeConfirmed ? .cliProxyAPI : .unknown, + modelProvider: modelProvider, + upstream: usageRecord.map(Self.upstream) ?? inventoryUpstream, + evidence: evidence.sorted { $0.rawValue < $1.rawValue }) + } + + private func sessionObservation(sessionID: String?) -> Observation? { + guard let sessionID = sessionID?.trimmingCharacters(in: .whitespacesAndNewlines), + !sessionID.isEmpty + else { return nil } + return self.observationsBySessionID[sessionID]?.first + } + + private func matchingObservation( + model: String, + sessionID: String?, + timestampUnixMs: Int64?) -> Observation? + { + guard let sessionID = sessionID?.trimmingCharacters(in: .whitespacesAndNewlines), + !sessionID.isEmpty, + let observations = self.observationsBySessionID[sessionID] + else { return nil } + + let canonicalModel = Self.canonicalModel(model) + let matchingModels = observations.filter { Self.canonicalModel($0.model) == canonicalModel } + guard !matchingModels.isEmpty else { return nil } + guard let timestampUnixMs else { + return matchingModels.count == 1 ? matchingModels[0] : nil + } + let timestamp = Date(timeIntervalSince1970: Double(timestampUnixMs) / 1000) + let candidates = matchingModels.filter { observation in + guard let observationTimestamp = observation.timestamp else { return true } + return abs(observationTimestamp.timeIntervalSince(timestamp)) <= Self.maximumRouteMatchDistance + } + return Self.uniqueClosest( + candidates, + target: timestamp, + timestamp: \.timestamp) + } + + private func matchingUsageRecord( + observation: Observation, + model: String, + tokens: TokenSignature?) -> CLIProxyAPIUsageRecord? + { + guard let observationTimestamp = observation.timestamp else { return nil } + let canonicalModel = Self.canonicalModel(model) + let candidates = self.usageRecords.filter { record in + guard !record.failed, + record.generate, + record.endpoint.lowercased().contains("/v1/messages"), + abs(record.timestamp.timeIntervalSince(observationTimestamp)) + <= Self.maximumTelemetryMatchDistance + else { return false } + let modelMatches = Self.canonicalModel(record.alias) == canonicalModel + || Self.canonicalModel(record.model) == canonicalModel + guard modelMatches else { return false } + return tokens.map { Self.tokensMatch($0, record.tokens) } ?? true + } + return candidates.count == 1 ? candidates[0] : nil + } + + private func authInventoryUpstream( + model: String, + modelProvider: CostUsageAttribution.ModelProvider) -> CostUsageAttribution.Upstream? + { + guard modelProvider == .openAI, + !self.observationsBySessionID.isEmpty, + !self.hasConfiguredOpenAIAPIUpstream + else { return nil } + let providers = Array(Set(self.authProviders.filter { + $0.provider.caseInsensitiveCompare("codex") == .orderedSame + })) + guard providers.count == 1, let provider = providers.first else { return nil } + return CostUsageAttribution.Upstream( + provider: provider.provider, + authType: provider.authType, + model: model.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + private static func tokensMatch( + _ tokens: TokenSignature, + _ telemetry: CLIProxyAPIUsageRecord.Tokens) -> Bool + { + guard telemetry.output == tokens.output else { return false } + if telemetry.cacheRead != 0 || telemetry.cacheCreation != 0 { + return telemetry.input == tokens.input + && telemetry.cacheRead == tokens.cacheRead + && telemetry.cacheCreation == tokens.cacheCreate + } + let claudeInputTotal = tokens.input + tokens.cacheRead + tokens.cacheCreate + return telemetry.input == tokens.input + || telemetry.input == claudeInputTotal + || telemetry.input + telemetry.cached == claudeInputTotal + } + + private static func uniqueClosest( + _ candidates: [T], + target: Date, + timestamp: (T) -> Date?) -> T? + { + let ranked = candidates.compactMap { candidate -> (candidate: T, distance: TimeInterval)? in + guard let date = timestamp(candidate) else { return (candidate, 0) } + return (candidate, abs(date.timeIntervalSince(target))) + } + .sorted { $0.distance < $1.distance } + guard let first = ranked.first else { return nil } + guard ranked.count == 1 || ranked[1].distance > first.distance else { return nil } + return first.candidate + } + + private static func upstream( + _ record: CLIProxyAPIUsageRecord) -> CostUsageAttribution.Upstream + { + let authType: CostUsageAttribution.Upstream.AuthType = switch record.authType + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + { + case "oauth": .oauth + case "api_key", "api-key", "apikey": .apiKey + default: .unknown + } + return CostUsageAttribution.Upstream( + provider: record.provider.trimmingCharacters(in: .whitespacesAndNewlines), + authType: authType, + model: record.model.trimmingCharacters(in: .whitespacesAndNewlines), + executorType: record.executorType?.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + private struct AuthFile: Decodable { + let type: String? + let disabled: Bool? + } + + private static func loadAuthProviders( + home: URL, + fileManager: FileManager) -> [AuthProvider] + { + guard let urls = try? fileManager.contentsOfDirectory( + at: home, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles]) + else { return [] } + + let decoder = JSONDecoder() + let providers = urls.compactMap { url -> AuthProvider? in + guard url.pathExtension.lowercased() == "json", + let values = try? url.resourceValues(forKeys: [.isRegularFileKey]), + values.isRegularFile == true, + let data = try? Data(contentsOf: url), + let auth = try? decoder.decode(AuthFile.self, from: data), + auth.disabled != true, + let rawType = auth.type?.trimmingCharacters(in: .whitespacesAndNewlines), + rawType.caseInsensitiveCompare("codex") == .orderedSame + else { return nil } + return AuthProvider(provider: "codex", authType: .oauth) + } + return Array(Set(providers)) + } + + private static func hasConfiguredOpenAIAPIUpstream( + home: URL, + fileManager: FileManager) -> Bool + { + let url = home.appendingPathComponent("config.yaml", isDirectory: false) + guard fileManager.fileExists(atPath: url.path), + let text = try? String(contentsOf: url, encoding: .utf8) + else { return false } + let conflictingKeys = ["codex-api-key", "openai-compatibility"] + return text.split(whereSeparator: \.isNewline).contains { line in + guard line.first?.isWhitespace != true else { return false } + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.hasPrefix("#") else { return false } + return conflictingKeys.contains { trimmed.hasPrefix("\($0):") } + } + } + + private static func loadObservations( + logDirectory: URL, + fileManager: FileManager) -> [Observation] + { + guard let urls = try? fileManager.contentsOfDirectory( + at: logDirectory, + includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey], + options: [.skipsHiddenFiles]) + else { return [] } + + return urls + .filter { $0.pathExtension.lowercased() == "log" } + .sorted { lhs, rhs in + let lhsValues = try? lhs.resourceValues(forKeys: [.contentModificationDateKey]) + let rhsValues = try? rhs.resourceValues(forKeys: [.contentModificationDateKey]) + let lhsDate = lhsValues?.contentModificationDate ?? .distantPast + let rhsDate = rhsValues?.contentModificationDate ?? .distantPast + return lhsDate > rhsDate + } + .prefix(500) + .compactMap(self.parseObservation) + } + + private static func parseObservation(url: URL) -> Observation? { + guard let handle = try? FileHandle(forReadingFrom: url) else { return nil } + defer { try? handle.close() } + guard let data = try? handle.read(upToCount: self.maxLogPrefixBytes), + let text = String(data: data, encoding: .utf8), + let bodyMarkerRange = text.range(of: self.requestBodyMarker) + else { return nil } + + let info = String(text[.. String? { + let prefix = "\(name):" + for line in text.split(whereSeparator: \.isNewline) { + guard line.hasPrefix(prefix) else { continue } + return line.dropFirst(prefix.count).trimmingCharacters(in: .whitespacesAndNewlines) + } + return nil + } + + private static func topLevelJSONStringValue(forKey key: String, in text: String) -> String? { + guard let data = text.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let value = object[key] as? String + else { + let escapedKey = NSRegularExpression.escapedPattern(for: key) + guard let regex = try? NSRegularExpression( + pattern: "\"\(escapedKey)\"\\s*:\\s*\"([^\"\\\\]*(?:\\\\.[^\"\\\\]*)*)\""), + let match = regex.firstMatch( + in: text, + range: NSRange(text.startIndex..., in: text)), + let valueRange = Range(match.range(at: 1), in: text) + else { return nil } + return String(text[valueRange]) + } + return value + } + + private static func canonicalModel(_ raw: String) -> String { + let codexNormalized = CostUsagePricing.normalizeCodexModel(raw) + return CostUsagePricing.normalizeClaudeModel(codexNormalized) + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + } +} diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift new file mode 100644 index 0000000000..5f426e541d --- /dev/null +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -0,0 +1,378 @@ +import Foundation + +struct CLIProxyAPIUsageRecord: Codable, Equatable, Sendable { + struct Tokens: Codable, Equatable, Sendable { + let input: Int + let output: Int + let reasoning: Int + let cached: Int + let cacheRead: Int + let cacheCreation: Int + let total: Int + + private enum CodingKeys: String, CodingKey { + case input = "input_tokens" + case output = "output_tokens" + case reasoning = "reasoning_tokens" + case cached = "cached_tokens" + case cacheRead = "cache_read_tokens" + case cacheCreation = "cache_creation_tokens" + case total = "total_tokens" + } + + init( + input: Int, + output: Int, + reasoning: Int = 0, + cached: Int = 0, + cacheRead: Int = 0, + cacheCreation: Int = 0, + total: Int) + { + self.input = input + self.output = output + self.reasoning = reasoning + self.cached = cached + self.cacheRead = cacheRead + self.cacheCreation = cacheCreation + self.total = total + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.input = try container.decodeIfPresent(Int.self, forKey: .input) ?? 0 + self.output = try container.decodeIfPresent(Int.self, forKey: .output) ?? 0 + self.reasoning = try container.decodeIfPresent(Int.self, forKey: .reasoning) ?? 0 + self.cached = try container.decodeIfPresent(Int.self, forKey: .cached) ?? 0 + self.cacheRead = try container.decodeIfPresent(Int.self, forKey: .cacheRead) ?? 0 + self.cacheCreation = try container.decodeIfPresent(Int.self, forKey: .cacheCreation) ?? 0 + self.total = try container.decodeIfPresent(Int.self, forKey: .total) ?? 0 + } + } + + let timestamp: Date + let provider: String + let executorType: String? + let model: String + let alias: String + let endpoint: String + let authType: String + let requestID: String + let failed: Bool + let generate: Bool + let tokens: Tokens + + private enum CodingKeys: String, CodingKey { + case timestamp + case provider + case executorType = "executor_type" + case model + case alias + case endpoint + case authType = "auth_type" + case requestID = "request_id" + case failed + case generate + case tokens + } + + init( + timestamp: Date, + provider: String, + executorType: String? = nil, + model: String, + alias: String, + endpoint: String, + authType: String, + requestID: String, + failed: Bool = false, + generate: Bool = true, + tokens: Tokens) + { + self.timestamp = timestamp + self.provider = provider + self.executorType = executorType + self.model = model + self.alias = alias + self.endpoint = endpoint + self.authType = authType + self.requestID = requestID + self.failed = failed + self.generate = generate + self.tokens = tokens + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.timestamp = try container.decode(Date.self, forKey: .timestamp) + self.provider = try container.decode(String.self, forKey: .provider) + self.executorType = try container.decodeIfPresent(String.self, forKey: .executorType) + self.model = try container.decode(String.self, forKey: .model) + self.alias = try container.decodeIfPresent(String.self, forKey: .alias) ?? self.model + self.endpoint = try container.decodeIfPresent(String.self, forKey: .endpoint) ?? "" + self.authType = try container.decodeIfPresent(String.self, forKey: .authType) ?? "" + self.requestID = try container.decodeIfPresent(String.self, forKey: .requestID) ?? "" + self.failed = try container.decodeIfPresent(Bool.self, forKey: .failed) ?? false + self.generate = try container.decodeIfPresent(Bool.self, forKey: .generate) ?? true + self.tokens = try container.decodeIfPresent(Tokens.self, forKey: .tokens) + ?? Tokens(input: 0, output: 0, total: 0) + } +} + +enum CLIProxyAPIUsageCacheIO { + private struct Cache: Codable { + var version: Int = 1 + var records: [CLIProxyAPIUsageRecord] = [] + } + + private static let maximumRecordAge: TimeInterval = 366 * 24 * 60 * 60 + + static func load(cacheRoot: URL? = nil) -> [CLIProxyAPIUsageRecord] { + guard let data = try? Data(contentsOf: self.cacheFileURL(cacheRoot: cacheRoot)), + let cache = try? self.decoder.decode(Cache.self, from: data), + cache.version == 1 + else { return [] } + return cache.records + } + + @discardableResult + static func merge( + _ records: [CLIProxyAPIUsageRecord], + cacheRoot: URL? = nil, + now: Date = Date()) -> Int + { + guard !records.isEmpty else { return 0 } + let cutoff = now.addingTimeInterval(-self.maximumRecordAge) + var byKey: [String: CLIProxyAPIUsageRecord] = [:] + for record in self.load(cacheRoot: cacheRoot) where record.timestamp >= cutoff { + byKey[self.recordKey(record)] = record + } + let priorCount = byKey.count + for record in records where record.timestamp >= cutoff { + byKey[self.recordKey(record)] = record + } + let cache = Cache(records: byKey.values.sorted { $0.timestamp < $1.timestamp }) + guard self.save(cache, cacheRoot: cacheRoot) else { return 0 } + return max(0, byKey.count - priorCount) + } + + static func cacheFileURL(cacheRoot: URL? = nil) -> URL { + let root = cacheRoot ?? FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + .appendingPathComponent("CodexBar", isDirectory: true) + return root + .appendingPathComponent("cost-usage", isDirectory: true) + .appendingPathComponent("cliproxyapi-usage-v1.json", isDirectory: false) + } + + private static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + guard let date = CostUsageDateParser.parse(value) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid CLIProxyAPI usage timestamp.") + } + return date + } + return decoder + }() + + private static let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.sortedKeys] + return encoder + }() + + private static func recordKey(_ record: CLIProxyAPIUsageRecord) -> String { + let requestID = record.requestID.trimmingCharacters(in: .whitespacesAndNewlines) + if !requestID.isEmpty { + return "request:\(requestID)" + } + let timestamp = Int64(record.timestamp.timeIntervalSince1970 * 1000) + return [ + "fallback", + String(timestamp), + record.provider, + record.model, + record.alias, + record.endpoint, + record.authType, + String(record.tokens.input), + String(record.tokens.cacheRead), + String(record.tokens.cacheCreation), + String(record.tokens.output), + ].joined(separator: ":") + } + + private static func save(_ cache: Cache, cacheRoot: URL?) -> Bool { + let url = self.cacheFileURL(cacheRoot: cacheRoot) + let directory = url.deletingLastPathComponent() + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let data = try self.encoder.encode(cache) + try data.write(to: url, options: [.atomic]) + return true + } catch { + return false + } + } +} + +public struct CLIProxyAPIConnectionSettings: Codable, Equatable, Sendable { + public static let defaultBaseURL = "http://127.0.0.1:8317" + + public let baseURL: String + public let managementKey: String + + public init(baseURL: String = Self.defaultBaseURL, managementKey: String) { + self.baseURL = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) + self.managementKey = managementKey.trimmingCharacters(in: .whitespacesAndNewlines) + } + + public var isConfigured: Bool { + !self.managementKey.isEmpty && self.resolvedBaseURL != nil + } + + var resolvedBaseURL: URL? { + let value = self.baseURL.isEmpty ? Self.defaultBaseURL : self.baseURL + guard let url = URL(string: value), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https", + let host = url.host?.lowercased(), + scheme == "https" || ["127.0.0.1", "::1", "localhost"].contains(host) + else { return nil } + return url + } +} + +public enum CLIProxyAPIConnectionSettingsStore { + private static let key = KeychainCacheStore.Key( + category: "integration", + identifier: "cliproxyapi-management") + + public static func load() -> CLIProxyAPIConnectionSettings? { + switch KeychainCacheStore.load(key: self.key, as: CLIProxyAPIConnectionSettings.self) { + case let .found(settings): settings + case .missing, .temporarilyUnavailable, .invalid: nil + } + } + + @discardableResult + public static func save(_ settings: CLIProxyAPIConnectionSettings) -> Bool { + guard settings.isConfigured else { return false } + return KeychainCacheStore.storeResult(key: self.key, entry: settings) + } + + @discardableResult + public static func clear() -> Bool { + KeychainCacheStore.clear(key: self.key) + } +} + +public enum CLIProxyAPIUsageCollectionResult: Equatable, Sendable { + case notConfigured + case collected(Int) + case failed(String) +} + +public enum CLIProxyAPIUsageCollector { + private static let maximumBatches = 10 + private static let batchSize = 100 + + public static func collect( + cacheRoot: URL? = nil, + settings: CLIProxyAPIConnectionSettings? = CLIProxyAPIConnectionSettingsStore.load()) async + -> CLIProxyAPIUsageCollectionResult + { + guard let settings, settings.isConfigured else { return .notConfigured } + do { + let client = CLIProxyAPIUsageQueueClient(settings: settings) + var records: [CLIProxyAPIUsageRecord] = [] + for _ in 0.. (Data, URLResponse) + + enum ClientError: LocalizedError { + case invalidBaseURL + case invalidResponse + case httpError(Int) + + var errorDescription: String? { + switch self { + case .invalidBaseURL: "Invalid CLIProxyAPI URL." + case .invalidResponse: "CLIProxyAPI returned an invalid response." + case let .httpError(status): "CLIProxyAPI returned HTTP \(status)." + } + } + } + + let settings: CLIProxyAPIConnectionSettings + let dataLoader: DataLoader + + init( + settings: CLIProxyAPIConnectionSettings, + dataLoader: @escaping DataLoader = Self.liveDataLoader) + { + self.settings = settings + self.dataLoader = dataLoader + } + + func pop(count: Int) async throws -> [CLIProxyAPIUsageRecord] { + guard let baseURL = self.settings.resolvedBaseURL, + var components = URLComponents( + url: baseURL.appendingPathComponent("v0/management/usage-queue"), + resolvingAgainstBaseURL: false) + else { throw ClientError.invalidBaseURL } + components.queryItems = [URLQueryItem(name: "count", value: String(max(1, count)))] + guard let url = components.url else { throw ClientError.invalidBaseURL } + + var request = URLRequest(url: url) + request.timeoutInterval = 5 + request.setValue("Bearer \(self.settings.managementKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await self.dataLoader(request) + guard let httpResponse = response as? HTTPURLResponse else { + throw ClientError.invalidResponse + } + guard (200..<300).contains(httpResponse.statusCode) else { + throw ClientError.httpError(httpResponse.statusCode) + } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + guard let date = CostUsageDateParser.parse(value) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid CLIProxyAPI usage timestamp.") + } + return date + } + return try decoder.decode([CLIProxyAPIUsageRecord].self, from: data) + } + + private static func liveDataLoader(_ request: URLRequest) async throws -> (Data, URLResponse) { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 5 + configuration.timeoutIntervalForResource = 10 + return try await URLSession(configuration: configuration).data(for: request) + } +} diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 295fa75e06..f684758f32 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -160,6 +160,10 @@ public struct CostUsageFetcher: Sendable { codexHomePath: String?) -> CostUsageScanner.Options { var options = override ?? CostUsageScanner.Options() + if override == nil { + options.cliProxyAPIHome = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".cli-proxy-api", isDirectory: true) + } if provider == .codex, let codexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines), !codexHomePath.isEmpty @@ -228,6 +232,7 @@ public struct CostUsageFetcher: Sendable { options.claudeLogProviderFilter = allowVertexClaudeFallback ? .all : .vertexAIOnly } else if provider == .claude { options.claudeLogProviderFilter = .excludeVertexAI + options.claudeAttributionFilter = .excludeCodexBackend } if forceRefresh || bypassScannerDebounce { options.refreshMinIntervalSeconds = 0 @@ -277,21 +282,19 @@ public struct CostUsageFetcher: Sendable { var projects: [CostUsageProjectBreakdown] = [] var sessions: [CostUsageSessionBreakdown] = [] var piDaily: CostUsageDailyReport? + var claudeProxyDaily: CostUsageDailyReport? if provider == .codex { - let roots = CostUsageScanner.codexSessionsRoots(options: scanOptions) - let cache = CostUsageScanner.codexCache( - CostUsageCacheIO.load(provider: .codex, cacheRoot: scanOptions.cacheRoot), - scopedTo: roots) let range = CostUsageScanner.CostUsageDayRange(since: since, until: until) - projects = CostUsageScanner.buildCodexProjectBreakdownsFromCache( - cache: cache, - range: range, - modelsDevCacheRoot: scanOptions.cacheRoot) - sessions = CostUsageScanner.buildCodexSessionBreakdownsFromCache( - cache: cache, + let supplemental = try Self.loadCodexSupplementalScan( + options: scanOptions, range: range, - modelsDevCacheRoot: scanOptions.cacheRoot, - sessionRoots: roots) + now: now, + includeClaudeProxy: shouldMergePiUsage, + checkCancellation: checkCancellation) + projects = supplemental.projects + sessions = supplemental.sessions + claudeProxyDaily = supplemental.claudeProxyDaily + daily = claudeProxyDaily.map { daily.merged(with: $0) } ?? daily } if includePiSessions, provider == .claude || (provider == .codex && shouldMergePiUsage) { let piReport = try PiSessionCostScanner.loadDailyReportCancellable( @@ -308,11 +311,13 @@ public struct CostUsageFetcher: Sendable { daily = CostUsageDailyReport.merged([daily, piReport]) } if provider == .codex { - projects = Self.mergedProjectBreakdowns( - projects + [piDaily.flatMap(Self.unknownProjectBreakdown(from:))].compactMap(\.self)) - if piDaily?.data.isEmpty == false { - sessions = [] - } + let finalized = Self.finalizeCodexSupplementalScan( + projects: projects, + sessions: sessions, + claudeProxyDaily: claudeProxyDaily, + piDaily: piDaily) + projects = finalized.projects + sessions = finalized.sessions } return (daily: daily, projects: projects, sessions: sessions) } @@ -486,6 +491,7 @@ public struct CostUsageFetcher: Sendable { var nativeScanAt: Date? var scanTimes: [Date] = [] var piMerged = false + var claudeProxyMerged = false if !cache.days.isEmpty, cache.roots == CostUsageScanner.codexRootsFingerprint(options: options), @@ -516,6 +522,40 @@ public struct CostUsageFetcher: Sendable { } } + let claudeCache = CostUsageCacheIO.load(provider: .claude, cacheRoot: options.cacheRoot) + if !claudeCache.days.isEmpty, + !CostUsageScanner.requestedWindowExpandsCache(range: range, cache: claudeCache) + { + let proxyDaily = CostUsageScanner.buildClaudeReportFromCache( + cache: claudeCache, + range: range, + attributionFilter: .codexBackendOnly, + attributionResolver: options.cliProxyAPIHome.map { + CLIProxyAPIAttributionResolver.load( + home: $0, + cacheRoot: options.cacheRoot) + }, + modelsDevCatalog: CostUsagePricing.modelsDevCatalog( + now: now, + cacheRoot: options.cacheRoot), + modelsDevCacheRoot: options.cacheRoot) + if !proxyDaily.data.isEmpty { + reports.append(proxyDaily) + claudeProxyMerged = true + if claudeCache.lastScanUnixMs > 0 { + scanTimes.append(Date( + timeIntervalSince1970: TimeInterval(claudeCache.lastScanUnixMs) / 1000)) + } + if let proxyProject = Self.unknownProjectBreakdown( + from: proxyDaily, + name: "Claude Code via CLIProxyAPI") + { + projects.append(proxyProject) + } + sessions = [] + } + } + if let piResult = PiSessionCostScanner.loadCachedDailyReportResult( provider: .codex, since: since, @@ -549,7 +589,7 @@ public struct CostUsageFetcher: Sendable { projects: Self.mergedProjectBreakdowns(projects), sessions: sessions, updatedAt: scanTimes.min()), - lastRefreshAt: piMerged ? nil : nativeScanAt) + lastRefreshAt: piMerged || claudeProxyMerged ? nil : nativeScanAt) } return cachedSnapshot.flatMap(\.self) } @@ -674,11 +714,86 @@ public struct CostUsageFetcher: Sendable { sessions: sessions, updatedAt: updatedAt ?? now) } +} - private static func unknownProjectBreakdown(from daily: CostUsageDailyReport) -> CostUsageProjectBreakdown? { +extension CostUsageFetcher { + private struct CodexSupplementalScan { + let projects: [CostUsageProjectBreakdown] + let sessions: [CostUsageSessionBreakdown] + let claudeProxyDaily: CostUsageDailyReport? + } + + private static func loadCodexSupplementalScan( + options: CostUsageScanner.Options, + range: CostUsageScanner.CostUsageDayRange, + now: Date, + includeClaudeProxy: Bool, + checkCancellation: @escaping CostUsageScanner.CancellationCheck) throws -> CodexSupplementalScan + { + let roots = CostUsageScanner.codexSessionsRoots(options: options) + let cache = CostUsageScanner.codexCache( + CostUsageCacheIO.load(provider: .codex, cacheRoot: options.cacheRoot), + scopedTo: roots) + let projects = CostUsageScanner.buildCodexProjectBreakdownsFromCache( + cache: cache, + range: range, + modelsDevCacheRoot: options.cacheRoot) + let sessions = CostUsageScanner.buildCodexSessionBreakdownsFromCache( + cache: cache, + range: range, + modelsDevCacheRoot: options.cacheRoot, + sessionRoots: roots) + + guard includeClaudeProxy else { + return CodexSupplementalScan( + projects: projects, + sessions: sessions, + claudeProxyDaily: nil) + } + var proxyOptions = options + proxyOptions.claudeLogProviderFilter = .excludeVertexAI + proxyOptions.claudeAttributionFilter = .codexBackendOnly + let proxyDaily = try CostUsageScanner.loadClaudeDaily( + provider: .claude, + range: range, + now: now, + options: proxyOptions, + checkCancellation: checkCancellation) + return CodexSupplementalScan( + projects: projects, + sessions: sessions, + claudeProxyDaily: proxyDaily.data.isEmpty ? nil : proxyDaily) + } + + private static func finalizeCodexSupplementalScan( + projects: [CostUsageProjectBreakdown], + sessions: [CostUsageSessionBreakdown], + claudeProxyDaily: CostUsageDailyReport?, + piDaily: CostUsageDailyReport?) -> ( + projects: [CostUsageProjectBreakdown], + sessions: [CostUsageSessionBreakdown]) + { + let mergedProjects = Self.mergedProjectBreakdowns( + projects + [ + claudeProxyDaily.flatMap { + Self.unknownProjectBreakdown( + from: $0, + name: "Claude Code via CLIProxyAPI") + }, + piDaily.flatMap { Self.unknownProjectBreakdown(from: $0) }, + ].compactMap(\.self)) + let hasUnattributedSessions = piDaily?.data.isEmpty == false + || claudeProxyDaily?.data.isEmpty == false + return (mergedProjects, hasUnattributedSessions ? [] : sessions) + } + + private static func unknownProjectBreakdown( + from daily: CostUsageDailyReport, + name: String = CostUsageProjectBreakdown.unknownProjectName) -> CostUsageProjectBreakdown? + { guard !daily.data.isEmpty else { return nil } return CostUsageProjectBreakdown( - name: CostUsageProjectBreakdown.unknownProjectName, + name: name, path: nil, totalTokens: daily.summary?.totalTokens, totalCostUSD: daily.summary?.totalCostUSD, @@ -686,7 +801,7 @@ public struct CostUsageFetcher: Sendable { modelBreakdowns: self.projectModelBreakdowns(from: daily.data), sources: [ CostUsageProjectSourceBreakdown( - name: CostUsageProjectBreakdown.unknownProjectName, + name: name, path: nil, totalTokens: daily.summary?.totalTokens, totalCostUSD: daily.summary?.totalCostUSD, @@ -798,28 +913,42 @@ public struct CostUsageFetcher: Sendable { } } - func build(modelName: String) -> CostUsageDailyReport.ModelBreakdown { + func build( + modelName: String, + attribution: CostUsageAttribution?) -> CostUsageDailyReport.ModelBreakdown + { CostUsageDailyReport.ModelBreakdown( modelName: modelName, costUSD: self.sawCost ? self.costUSD : nil, - totalTokens: self.sawTotalTokens ? self.totalTokens : nil) + totalTokens: self.sawTotalTokens ? self.totalTokens : nil, + attribution: attribution) } } + private struct ProjectBreakdownKey: Hashable { + let modelName: String + let attribution: CostUsageAttribution? + } + private static func projectModelBreakdowns( from entries: [CostUsageDailyReport.Entry]) -> [CostUsageDailyReport.ModelBreakdown]? { - var accumulators: [String: ProjectBreakdownAccumulator] = [:] + var accumulators: [ProjectBreakdownKey: ProjectBreakdownAccumulator] = [:] for entry in entries { for breakdown in entry.modelBreakdowns ?? [] { - var accumulator = accumulators[breakdown.modelName] ?? ProjectBreakdownAccumulator() + let key = ProjectBreakdownKey( + modelName: breakdown.modelName, + attribution: breakdown.attribution) + var accumulator = accumulators[key] ?? ProjectBreakdownAccumulator() accumulator.add(breakdown) - accumulators[breakdown.modelName] = accumulator + accumulators[key] = accumulator } } guard !accumulators.isEmpty else { return nil } - return accumulators.map { modelName, accumulator in - accumulator.build(modelName: modelName) + return accumulators.map { key, accumulator in + accumulator.build( + modelName: key.modelName, + attribution: key.attribution) } .sorted { lhs, rhs in let lhsCost = lhs.costUSD ?? -1 @@ -835,7 +964,9 @@ public struct CostUsageFetcher: Sendable { return lhs.modelName > rhs.modelName } } +} +extension CostUsageFetcher { static func selectCurrentSession(from sessions: [CostUsageSessionReport.Entry]) -> CostUsageSessionReport.Entry? { diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index d5b7849b0b..153d8d10aa 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -272,6 +272,111 @@ public struct CostUsageProjectSourceBreakdown: Sendable, Equatable { } } +public struct CostUsageAttribution: Sendable, Codable, Equatable, Hashable { + public enum Client: String, Sendable, Codable, Hashable { + case claudeCode + } + + public enum Route: String, Sendable, Codable, Hashable { + case unknown + case cliProxyAPI + } + + public enum ModelProvider: String, Sendable, Codable, Hashable { + case openAI + case anthropic + case google + case unknown + } + + public struct Upstream: Sendable, Codable, Equatable, Hashable { + public enum AuthType: String, Sendable, Codable, Hashable { + case oauth + case apiKey + case unknown + } + + public let provider: String + public let authType: AuthType + public let model: String? + public let executorType: String? + + public init( + provider: String, + authType: AuthType, + model: String? = nil, + executorType: String? = nil) + { + self.provider = provider + self.authType = authType + self.model = model + self.executorType = executorType + } + + public var isCodex: Bool { + self.provider.trimmingCharacters(in: .whitespacesAndNewlines) + .caseInsensitiveCompare("codex") == .orderedSame + } + + public var providerDisplayName: String { + switch self.provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "codex": "Codex" + case "claude": "Claude" + case "gemini": "Gemini" + case "gemini-interactions": "Gemini Interactions" + case "aistudio": "AI Studio" + case "vertex": "Vertex AI" + case "antigravity": "Antigravity" + case "xai": "xAI" + case "kimi": "Kimi" + case "openrouter": "OpenRouter" + case let provider where provider.isEmpty: "Unknown" + case let provider: provider + } + } + + public var authDisplayName: String? { + switch self.authType { + case .oauth: "OAuth" + case .apiKey: "API key" + case .unknown: nil + } + } + + public var displayName: String { + guard let authDisplayName else { return self.providerDisplayName } + return "\(self.providerDisplayName) \(authDisplayName)" + } + } + + public enum Evidence: String, Sendable, Codable, Hashable { + case cliProxyAuthInventory + case modelProvider + case cliProxyRequestLog + case cliProxyUsageTelemetry + } + + public let client: Client + public let route: Route + public let modelProvider: ModelProvider + public let upstream: Upstream? + public let evidence: [Evidence] + + public init( + client: Client, + route: Route, + modelProvider: ModelProvider = .unknown, + upstream: Upstream? = nil, + evidence: [Evidence] = []) + { + self.client = client + self.route = route + self.modelProvider = modelProvider + self.upstream = upstream + self.evidence = evidence + } +} + public struct CostUsageDailyReport: Sendable, Decodable { public struct ModelBreakdown: Sendable, Decodable, Equatable { public let modelName: String @@ -282,6 +387,7 @@ public struct CostUsageDailyReport: Sendable, Decodable { public let priorityCostUSD: Double? public let standardTokens: Int? public let priorityTokens: Int? + public let attribution: CostUsageAttribution? private enum CodingKeys: String, CodingKey { case modelName @@ -294,6 +400,7 @@ public struct CostUsageDailyReport: Sendable, Decodable { case priorityCostUSD case standardTokens case priorityTokens + case attribution } public init(from decoder: Decoder) throws { @@ -310,6 +417,7 @@ public struct CostUsageDailyReport: Sendable, Decodable { self.priorityCostUSD = try container.decodeIfPresent(Double.self, forKey: .priorityCostUSD) self.standardTokens = try container.decodeIfPresent(Int.self, forKey: .standardTokens) self.priorityTokens = try container.decodeIfPresent(Int.self, forKey: .priorityTokens) + self.attribution = try container.decodeIfPresent(CostUsageAttribution.self, forKey: .attribution) } public init( @@ -320,7 +428,8 @@ public struct CostUsageDailyReport: Sendable, Decodable { standardCostUSD: Double? = nil, priorityCostUSD: Double? = nil, standardTokens: Int? = nil, - priorityTokens: Int? = nil) + priorityTokens: Int? = nil, + attribution: CostUsageAttribution? = nil) { self.modelName = modelName self.costUSD = costUSD @@ -330,6 +439,7 @@ public struct CostUsageDailyReport: Sendable, Decodable { self.priorityCostUSD = priorityCostUSD self.standardTokens = standardTokens self.priorityTokens = priorityTokens + self.attribution = attribution } } @@ -527,6 +637,11 @@ public struct CostUsageDailyReport: Sendable, Decodable { } extension CostUsageDailyReport { + private struct BreakdownKey: Hashable { + let modelName: String + let attribution: CostUsageAttribution? + } + private struct BreakdownAccumulator { var totalTokens: Int = 0 var sawTotalTokens = false @@ -568,15 +683,16 @@ extension CostUsageDailyReport { } } - func build(modelName: String) -> ModelBreakdown { + func build(key: BreakdownKey) -> ModelBreakdown { ModelBreakdown( - modelName: modelName, + modelName: key.modelName, costUSD: self.sawCost ? self.costUSD : nil, totalTokens: self.sawTotalTokens ? self.totalTokens : nil, standardCostUSD: self.sawStandardCost ? self.standardCostUSD : nil, priorityCostUSD: self.sawPriorityCost ? self.priorityCostUSD : nil, standardTokens: self.sawStandardTokens ? self.standardTokens : nil, - priorityTokens: self.sawPriorityTokens ? self.priorityTokens : nil) + priorityTokens: self.sawPriorityTokens ? self.priorityTokens : nil, + attribution: key.attribution) } } @@ -595,7 +711,7 @@ extension CostUsageDailyReport { var costUSD: Double = 0 var sawCost = false var modelsUsed: Set = [] - var breakdowns: [String: BreakdownAccumulator] = [:] + var breakdowns: [BreakdownKey: BreakdownAccumulator] = [:] mutating func add(_ entry: Entry) { let entryDerivedTotalTokens = (entry.inputTokens ?? 0) @@ -633,9 +749,12 @@ extension CostUsageDailyReport { } if let modelBreakdowns = entry.modelBreakdowns { for breakdown in modelBreakdowns { - var accumulator = self.breakdowns[breakdown.modelName] ?? BreakdownAccumulator() + let key = BreakdownKey( + modelName: breakdown.modelName, + attribution: breakdown.attribution) + var accumulator = self.breakdowns[key] ?? BreakdownAccumulator() accumulator.add(breakdown) - self.breakdowns[breakdown.modelName] = accumulator + self.breakdowns[key] = accumulator self.modelsUsed.insert(breakdown.modelName) } } @@ -657,8 +776,8 @@ extension CostUsageDailyReport { guard !self.breakdowns.isEmpty else { return nil } return CostUsageDailyReport.sortedModelBreakdowns( self.breakdowns - .map { modelName, accumulator in - accumulator.build(modelName: modelName) + .map { key, accumulator in + accumulator.build(key: key) }) }() let modelsUsed = self.modelsUsed.isEmpty ? nil : self.modelsUsed.sorted() @@ -767,7 +886,16 @@ extension CostUsageDailyReport { return lhsTokens > rhsTokens } - return lhs.modelName > rhs.modelName + if lhs.modelName != rhs.modelName { + return lhs.modelName > rhs.modelName + } + let lhsAttribution = lhs.attribution.map { + "\($0.client.rawValue):\($0.route.rawValue):\($0.upstream?.provider ?? "")" + } ?? "" + let rhsAttribution = rhs.attribution.map { + "\($0.client.rawValue):\($0.route.rawValue):\($0.upstream?.provider ?? "")" + } ?? "" + return lhsAttribution > rhsAttribution } } } diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index d46bf4b5b2..ee6bc93f3d 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 = "48ac20dad61e9a7f" + static let value = "cbccd03ce19eadfd" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index 7323a07c43..de20fe83da 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -13,7 +13,7 @@ enum CostUsageCacheIO { case .codex: 10 case .claude, .vertexai: - 5 + 6 default: 1 } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ModelProvider.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ModelProvider.swift new file mode 100644 index 0000000000..687eba810f --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ModelProvider.swift @@ -0,0 +1,42 @@ +import Foundation + +extension CostUsagePricing { + static func modelProvider( + for model: String, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil) -> CostUsageAttribution.ModelProvider + { + if self.isOpenAIModel( + model, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + { + return .openAI + } + + if self.claudeCostUSD( + model: model, + inputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) != nil + { + return .anthropic + } + + let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.lowercased().hasPrefix("gemini-") + || modelsDevCatalog?.pricing(providerID: "google", modelID: trimmed) != nil + || ModelsDevPricingPipeline.lookup( + providerID: "google", + modelID: trimmed, + cacheRoot: modelsDevCacheRoot) != nil + { + return .google + } + + return .unknown + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 3727bf2ce2..f04a574671 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -484,6 +484,22 @@ enum CostUsagePricing { self.normalizeCodexModel(raw) == self.codexUnattributedModel } + static func isOpenAIModel( + _ model: String, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil) -> Bool + { + let normalized = self.normalizeCodexModel(model) + if normalized != self.codexUnattributedModel, self.codex[normalized] != nil { + return true + } + return self.modelsDevLookup( + providerID: self.codexModelsDevProviderID, + model: model, + catalog: modelsDevCatalog, + cacheRoot: modelsDevCacheRoot) != nil + } + static func codexDisplayLabel(model: String) -> String? { let key = self.normalizeCodexModel(model) return self.codex[key]?.displayLabel @@ -585,6 +601,31 @@ enum CostUsagePricing { outputTokens: outputTokens) } + static func claudeProxyCodexCostUSD( + model: String, + inputTokens: Int, + cacheReadInputTokens: Int, + cacheCreationInputTokens: Int, + outputTokens: Int, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil) -> Double? + { + let uncachedInput = max(0, inputTokens) + let cachedInput = max(0, cacheReadInputTokens) + let cacheWriteInput = max(0, cacheCreationInputTokens) + let totalInput = [uncachedInput, cachedInput, cacheWriteInput].reduce(0) { total, component in + total > Int.max - component ? Int.max : total + component + } + return self.codexCostUSD( + model: model, + inputTokens: totalInput, + cachedInputTokens: cachedInput, + outputTokens: outputTokens, + cacheWriteInputTokens: cacheWriteInput, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + } + static func codexPriorityCostUSD( model: String, inputTokens: Int, diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift index 8bdbeec25d..20ea28aedc 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift @@ -1508,7 +1508,16 @@ extension CostUsageScanner { return lhsTokens > rhsTokens } - return lhs.modelName > rhs.modelName + if lhs.modelName != rhs.modelName { + return lhs.modelName > rhs.modelName + } + let lhsAttribution = lhs.attribution.map { + "\($0.client.rawValue):\($0.route.rawValue):\($0.upstream?.provider ?? "")" + } ?? "" + let rhsAttribution = rhs.attribution.map { + "\($0.client.rawValue):\($0.route.rawValue):\($0.upstream?.provider ?? "")" + } ?? "" + return lhsAttribution > rhsAttribution } } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 2f61dabada..5cf7314633 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -13,9 +13,18 @@ extension CostUsageScanner { let costPriced: Bool } + private struct ClaudeRawTokens { + let input: Int + let cacheRead: Int + let cacheCreate: Int + let cacheCreate1h: Int + let output: Int + } + private struct ClaudeDayModelKey: Hashable { let day: String let model: String + let attribution: CostUsageAttribution? } private struct ClaudeRepricedCost { @@ -24,6 +33,83 @@ extension CostUsageScanner { var unresolved = false } + private struct ClaudeModelResolution { + let normalizedModel: String + let cost: Double? + let attribution: CostUsageAttribution? + } + + private struct ClaudeModelResolutionContext { + let pricingDate: Date + let sessionID: String? + let timestampUnixMs: Int64? + let attributionResolver: CLIProxyAPIAttributionResolver? + let modelsDevCatalog: ModelsDevCatalog? + let modelsDevCacheRoot: URL? + } + + private static func resolveClaudeModel( + model: String, + tokens: ClaudeRawTokens, + context: ClaudeModelResolutionContext) -> ClaudeModelResolution + { + let modelProvider = CostUsagePricing.modelProvider( + for: model, + modelsDevCatalog: context.modelsDevCatalog, + modelsDevCacheRoot: context.modelsDevCacheRoot) + let resolvedAttribution = context.attributionResolver?.attribution( + model: model, + modelProvider: modelProvider, + sessionID: context.sessionID, + timestampUnixMs: context.timestampUnixMs, + tokens: .init( + input: tokens.input, + cacheRead: tokens.cacheRead, + cacheCreate: tokens.cacheCreate, + output: tokens.output)) + ?? CostUsageAttribution( + client: .claudeCode, + route: .unknown, + modelProvider: modelProvider, + evidence: [.modelProvider]) + let attribution = resolvedAttribution.route == .cliProxyAPI || modelProvider != .anthropic + ? resolvedAttribution + : nil + let cost: Double? = if modelProvider == .openAI { + CostUsagePricing.claudeProxyCodexCostUSD( + model: model, + inputTokens: tokens.input, + cacheReadInputTokens: tokens.cacheRead, + cacheCreationInputTokens: tokens.cacheCreate, + outputTokens: tokens.output, + modelsDevCatalog: context.modelsDevCatalog, + modelsDevCacheRoot: context.modelsDevCacheRoot) + } else if modelProvider == .anthropic { + CostUsagePricing.claudeCostUSD( + model: model, + inputTokens: tokens.input, + cacheReadInputTokens: tokens.cacheRead, + cacheCreationInputTokens: tokens.cacheCreate, + cacheCreationInputTokens1h: tokens.cacheCreate1h, + outputTokens: tokens.output, + pricingDate: context.pricingDate, + modelsDevCatalog: context.modelsDevCatalog, + modelsDevCacheRoot: context.modelsDevCacheRoot) + } else { nil } + let normalizedModel = switch modelProvider { + case .openAI: + CostUsagePricing.normalizeCodexModel(model) + case .anthropic: + CostUsagePricing.normalizeClaudeModel(model) + case .google, .unknown: + model.trimmingCharacters(in: .whitespacesAndNewlines) + } + return ClaudeModelResolution( + normalizedModel: normalizedModel, + cost: cost, + attribution: attribution) + } + static func defaultClaudeProjectsRoots( options: Options, environment: [String: String] = ProcessInfo.processInfo.environment, @@ -77,6 +163,7 @@ extension CostUsageScanner { range: CostUsageDayRange, providerFilter: ClaudeLogProviderFilter, startOffset: Int64 = 0, + attributionResolver: CLIProxyAPIAttributionResolver? = nil, modelsDevCatalog: ModelsDevCatalog? = nil, modelsDevCacheRoot: URL? = nil) -> ClaudeParseResult { @@ -86,6 +173,7 @@ extension CostUsageScanner { range: range, providerFilter: providerFilter, startOffset: startOffset, + attributionResolver: attributionResolver, modelsDevCatalog: modelsDevCatalog, modelsDevCacheRoot: modelsDevCacheRoot, checkCancellation: nil)) ?? ClaudeParseResult(days: [:], rows: [], parsedBytes: startOffset) @@ -96,6 +184,7 @@ extension CostUsageScanner { range: CostUsageDayRange, providerFilter: ClaudeLogProviderFilter, startOffset: Int64 = 0, + attributionResolver: CLIProxyAPIAttributionResolver? = nil, modelsDevCatalog: ModelsDevCatalog? = nil, modelsDevCacheRoot: URL? = nil, checkCancellation: CancellationCheck? = nil) throws -> ClaudeParseResult @@ -178,17 +267,28 @@ extension CostUsageScanner { let output = max(0, toInt(usage["output_tokens"])) if input == 0, cacheCreate == 0, cacheRead == 0, output == 0 { return } - let cost = CostUsagePricing.claudeCostUSD( + let rawTokens = ClaudeRawTokens( + input: input, + cacheRead: cacheRead, + cacheCreate: cacheCreate, + cacheCreate1h: cacheCreate1h, + output: output) + let sessionId = obj["sessionId"] as? String + ?? obj["session_id"] as? String + ?? (obj["metadata"] as? [String: Any])?["sessionId"] as? String + ?? (message["metadata"] as? [String: Any])?["sessionId"] as? String + let timestampUnixMs = Int64((timestamp.timeIntervalSince1970 * 1000).rounded()) + let modelResolution = Self.resolveClaudeModel( model: model, - inputTokens: input, - cacheReadInputTokens: cacheRead, - cacheCreationInputTokens: cacheCreate, - cacheCreationInputTokens1h: cacheCreate1h, - outputTokens: output, - pricingDate: timestamp, - modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot) - let costNanos = cost.map { Int(($0 * costScale).rounded()) } ?? 0 + tokens: rawTokens, + context: ClaudeModelResolutionContext( + pricingDate: timestamp, + sessionID: sessionId, + timestampUnixMs: timestampUnixMs, + attributionResolver: attributionResolver, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot)) + let costNanos = modelResolution.cost.map { Int(($0 * costScale).rounded()) } ?? 0 let tokens = ClaudeTokens( input: input, cacheRead: cacheRead, @@ -196,7 +296,7 @@ extension CostUsageScanner { cacheCreate1h: cacheCreate1h, output: output, costNanos: costNanos, - costPriced: cost != nil) + costPriced: modelResolution.cost != nil) guard CostUsageDayRange.isInRange( dayKey: dayKey, @@ -206,18 +306,13 @@ extension CostUsageScanner { let messageId = message["id"] as? String let requestId = obj["requestId"] as? String - let sessionId = obj["sessionId"] as? String - ?? obj["session_id"] as? String - ?? (obj["metadata"] as? [String: Any])?["sessionId"] as? String - ?? (message["metadata"] as? [String: Any])?["sessionId"] as? String - let normalizedModel = CostUsagePricing.normalizeClaudeModel(model) let row = ClaudeUsageRow( dayKey: dayKey, - model: normalizedModel, + model: modelResolution.normalizedModel, sessionId: sessionId, messageId: messageId, requestId: requestId, - timestampUnixMs: Int64((timestamp.timeIntervalSince1970 * 1000).rounded()), + timestampUnixMs: timestampUnixMs, isSidechain: toBool(obj["isSidechain"]), pathRole: pathRole, input: tokens.input, @@ -226,7 +321,8 @@ extension CostUsageScanner { cacheCreate1h: tokens.cacheCreate1h, output: tokens.output, costNanos: tokens.costNanos, - costPriced: tokens.costPriced) + costPriced: tokens.costPriced, + attribution: modelResolution.attribution) // Streaming chunks share message.id + requestId inside a file. // Keep overwriting so the final cumulative chunk wins. @@ -508,6 +604,7 @@ extension CostUsageScanner { let range: CostUsageDayRange let providerFilter: ClaudeLogProviderFilter let forceFullScan: Bool + let attributionResolver: CLIProxyAPIAttributionResolver? let modelsDevCatalog: ModelsDevCatalog? let modelsDevCacheRoot: URL? let checkCancellation: CancellationCheck? @@ -517,6 +614,7 @@ extension CostUsageScanner { range: CostUsageDayRange, providerFilter: ClaudeLogProviderFilter, forceFullScan: Bool, + attributionResolver: CLIProxyAPIAttributionResolver?, modelsDevCatalog: ModelsDevCatalog?, modelsDevCacheRoot: URL?, checkCancellation: CancellationCheck?) @@ -526,6 +624,7 @@ extension CostUsageScanner { self.range = range self.providerFilter = providerFilter self.forceFullScan = forceFullScan + self.attributionResolver = attributionResolver self.modelsDevCatalog = modelsDevCatalog self.modelsDevCacheRoot = modelsDevCacheRoot self.checkCancellation = checkCancellation @@ -560,6 +659,7 @@ extension CostUsageScanner { range: state.range, providerFilter: state.providerFilter, startOffset: startOffset, + attributionResolver: state.attributionResolver, modelsDevCatalog: state.modelsDevCatalog, modelsDevCacheRoot: state.modelsDevCacheRoot, checkCancellation: state.checkCancellation) @@ -577,6 +677,7 @@ extension CostUsageScanner { fileURL: url, range: state.range, providerFilter: state.providerFilter, + attributionResolver: state.attributionResolver, modelsDevCatalog: state.modelsDevCatalog, modelsDevCacheRoot: state.modelsDevCacheRoot, checkCancellation: state.checkCancellation) @@ -661,13 +762,22 @@ extension CostUsageScanner { let refreshMs = Int64(max(0, options.refreshMinIntervalSeconds) * 1000) let windowExpanded = Self.requestedWindowExpandsCache(range: range, cache: cache) + let requiresRowBackfill = cache.files.values.contains { + $0.claudeRows == nil && !$0.days.isEmpty + } let shouldRefresh = options.forceRescan || windowExpanded + || requiresRowBackfill || refreshMs == 0 || cache.lastScanUnixMs == 0 || nowMs - cache.lastScanUnixMs > refreshMs let providerFilter = options.claudeLogProviderFilter + let attributionResolver = options.cliProxyAPIHome.map { + CLIProxyAPIAttributionResolver.load( + home: $0, + cacheRoot: options.cacheRoot) + } var touched: Set = [] @@ -681,7 +791,10 @@ extension CostUsageScanner { cache: cache, range: range, providerFilter: providerFilter, - forceFullScan: options.forceRescan || windowExpanded, + forceFullScan: options.forceRescan + || windowExpanded + || requiresRowBackfill, + attributionResolver: attributionResolver, modelsDevCatalog: modelsDevCatalog, modelsDevCacheRoot: options.cacheRoot, checkCancellation: checkCancellation) @@ -715,68 +828,169 @@ extension CostUsageScanner { return Self.buildClaudeReportFromCache( cache: cache, range: range, + attributionFilter: options.claudeAttributionFilter, + attributionResolver: attributionResolver, modelsDevCatalog: modelsDevCatalog, modelsDevCacheRoot: options.cacheRoot) } - private static func buildClaudeReportFromCache( - cache: CostUsageCache, - range: CostUsageDayRange, - modelsDevCatalog: ModelsDevCatalog? = nil, - modelsDevCacheRoot: URL? = nil) -> CostUsageDailyReport - { - var entries: [CostUsageDailyReport.Entry] = [] - var totalInput = 0 - var totalOutput = 0 - var totalCacheRead = 0 - var totalCacheCreate = 0 - var totalTokens = 0 - var totalCost: Double = 0 - var costSeen = false - let costScale = 1_000_000_000.0 + private struct ClaudeReportAggregation { + var dayModels: [String: [ClaudeDayModelKey: [Int]]] = [:] var repricedCosts: [ClaudeDayModelKey: ClaudeRepricedCost] = [:] + } + private static func aggregateClaudeRows( + cache: CostUsageCache, + attributionFilter: ClaudeAttributionFilter, + attributionResolver: CLIProxyAPIAttributionResolver?, + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> ClaudeReportAggregation + { + var result = ClaudeReportAggregation() for row in Self.reconciledClaudeRows(cache: cache) { - let key = ClaudeDayModelKey(day: row.dayKey, model: row.model) - var aggregate = repricedCosts[key] ?? ClaudeRepricedCost() - aggregate.sampleCount += 1 - let isPriced = row.costPriced ?? (row.costNanos > 0) - let currentPricingCost = CostUsagePricing.claudeCostUSD( + let modelProvider = if let cachedProvider = row.attribution?.modelProvider, + cachedProvider != .unknown + { + cachedProvider + } else { + CostUsagePricing.modelProvider( + for: row.model, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + } + let liveAttribution = attributionResolver?.attribution( model: row.model, - inputTokens: row.input, - cacheReadInputTokens: row.cacheRead, - cacheCreationInputTokens: row.cacheCreate, - cacheCreationInputTokens1h: row.cacheCreate1h ?? 0, - outputTokens: row.output, - pricingDate: row.timestampUnixMs.map { - Date(timeIntervalSince1970: Double($0) / 1000) - }, + modelProvider: modelProvider, + sessionID: row.sessionId, + timestampUnixMs: row.timestampUnixMs, + tokens: .init( + input: row.input, + cacheRead: row.cacheRead, + cacheCreate: row.cacheCreate, + output: row.output)) + let attribution = if let liveAttribution { + liveAttribution.route == .cliProxyAPI || modelProvider != .anthropic + ? liveAttribution + : nil + } else { + row.attribution + } + let isCodexBackend = attribution?.route == .cliProxyAPI + && attribution?.upstream?.isCodex == true + let includeRow = switch attributionFilter { + case .all: true + case .codexBackendOnly: isCodexBackend + case .excludeCodexBackend: !isCodexBackend + } + guard includeRow else { continue } + + var models = result.dayModels[row.dayKey] ?? [:] + let key = ClaudeDayModelKey( + day: row.dayKey, + model: row.model, + attribution: attribution) + var packed = models[key] ?? [0, 0, 0, 0, 0, 0] + packed[0] += row.input + packed[1] += row.cacheRead + packed[2] += row.cacheCreate + packed[3] += row.output + packed[5] += 1 + models[key] = packed + result.dayModels[row.dayKey] = models + + var cost = result.repricedCosts[key] ?? ClaudeRepricedCost() + cost.sampleCount += 1 + let wasPriced = row.costPriced ?? (row.costNanos > 0) + let currentCost = Self.currentClaudeRowCost( + row, + modelProvider: modelProvider, modelsDevCatalog: modelsDevCatalog, modelsDevCacheRoot: modelsDevCacheRoot) - let cost: Double? = if isPriced, row.costNanos == 0 { + let resolvedCost: Double? = if wasPriced, row.costNanos == 0 { 0 - } else if let currentPricingCost { - currentPricingCost - } else if isPriced { - Double(row.costNanos) / costScale + } else if let currentCost { + currentCost + } else if wasPriced { + Double(row.costNanos) / Self.costScale } else { nil } - if let cost { - aggregate.total += cost + if let resolvedCost { + cost.total += resolvedCost } else { - aggregate.unresolved = true + cost.unresolved = true } - repricedCosts[key] = aggregate + result.repricedCosts[key] = cost } + return result + } - let dayKeys = cache.days.keys.sorted().filter { + private static func currentClaudeRowCost( + _ row: ClaudeUsageRow, + modelProvider: CostUsageAttribution.ModelProvider, + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> Double? + { + if modelProvider == .openAI { + return CostUsagePricing.claudeProxyCodexCostUSD( + model: row.model, + inputTokens: row.input, + cacheReadInputTokens: row.cacheRead, + cacheCreationInputTokens: row.cacheCreate, + outputTokens: row.output, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + } + guard modelProvider == .anthropic else { return nil } + return CostUsagePricing.claudeCostUSD( + model: row.model, + inputTokens: row.input, + cacheReadInputTokens: row.cacheRead, + cacheCreationInputTokens: row.cacheCreate, + cacheCreationInputTokens1h: row.cacheCreate1h ?? 0, + outputTokens: row.output, + pricingDate: row.timestampUnixMs.map { + Date(timeIntervalSince1970: Double($0) / 1000) + }, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + } + + static func buildClaudeReportFromCache( + cache: CostUsageCache, + range: CostUsageDayRange, + attributionFilter: ClaudeAttributionFilter = .all, + attributionResolver: CLIProxyAPIAttributionResolver? = nil, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil) -> CostUsageDailyReport + { + var entries: [CostUsageDailyReport.Entry] = [] + var totalInput = 0 + var totalOutput = 0 + var totalCacheRead = 0 + var totalCacheCreate = 0 + var totalTokens = 0 + var totalCost: Double = 0 + var costSeen = false + let aggregation = Self.aggregateClaudeRows( + cache: cache, + attributionFilter: attributionFilter, + attributionResolver: attributionResolver, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + let dayModels = aggregation.dayModels + let repricedCosts = aggregation.repricedCosts + + let dayKeys = dayModels.keys.sorted().filter { CostUsageDayRange.isInRange(dayKey: $0, since: range.sinceKey, until: range.untilKey) } for day in dayKeys { - guard let models = cache.days[day] else { continue } - let modelNames = models.keys.sorted() + guard let models = dayModels[day] else { continue } + let modelKeys = models.keys.sorted { + if $0.model != $1.model { return $0.model < $1.model } + return ($0.attribution?.upstream?.provider ?? "") < ($1.attribution?.upstream?.provider ?? "") + } var dayInput = 0 var dayOutput = 0 @@ -787,8 +1001,9 @@ extension CostUsageScanner { var dayCost: Double = 0 var dayCostSeen = false - for model in modelNames { - let packed = models[model] ?? [0, 0, 0, 0] + for modelKey in modelKeys { + let model = modelKey.model + let packed = models[modelKey] ?? [0, 0, 0, 0] let input = packed[safe: 0] ?? 0 let cacheRead = packed[safe: 1] ?? 0 let cacheCreate = packed[safe: 2] ?? 0 @@ -802,7 +1017,7 @@ extension CostUsageScanner { dayCacheCreate += cacheCreate dayOutput += output - let repricedCost = repricedCosts[ClaudeDayModelKey(day: day, model: model)] + let repricedCost = repricedCosts[modelKey] let currentPricingCost: Double? = if let repricedCost, repricedCost.sampleCount == sampleCount, !repricedCost.unresolved @@ -816,7 +1031,8 @@ extension CostUsageScanner { CostUsageDailyReport.ModelBreakdown( modelName: model, costUSD: cost, - totalTokens: totalTokens)) + totalTokens: totalTokens, + attribution: modelKey.attribution)) if let cost { dayCost += cost dayCostSeen = true @@ -835,7 +1051,7 @@ extension CostUsageScanner { cacheCreationTokens: dayCacheCreate, totalTokens: dayTotal, costUSD: entryCost, - modelsUsed: modelNames, + modelsUsed: Array(Set(modelKeys.map(\.model))).sorted(), modelBreakdowns: sortedBreakdown)) totalInput += dayInput diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 7ff583f33d..cd2266996b 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -24,6 +24,12 @@ enum CostUsageScanner { case excludeVertexAI } + enum ClaudeAttributionFilter { + case all + case codexBackendOnly + case excludeCodexBackend + } + struct Options { var codexSessionsRoot: URL? var claudeProjectsRoots: [URL]? @@ -31,6 +37,8 @@ enum CostUsageScanner { var codexTraceDatabaseURL: URL? var refreshMinIntervalSeconds: TimeInterval = 60 var claudeLogProviderFilter: ClaudeLogProviderFilter = .all + var claudeAttributionFilter: ClaudeAttributionFilter = .all + var cliProxyAPIHome: URL? /// Force a full rescan, ignoring per-file cache and incremental offsets. var forceRescan: Bool = false @@ -40,6 +48,8 @@ enum CostUsageScanner { cacheRoot: URL? = nil, codexTraceDatabaseURL: URL? = nil, claudeLogProviderFilter: ClaudeLogProviderFilter = .all, + claudeAttributionFilter: ClaudeAttributionFilter = .all, + cliProxyAPIHome: URL? = nil, forceRescan: Bool = false) { self.codexSessionsRoot = codexSessionsRoot @@ -47,6 +57,8 @@ enum CostUsageScanner { self.cacheRoot = cacheRoot self.codexTraceDatabaseURL = codexTraceDatabaseURL self.claudeLogProviderFilter = claudeLogProviderFilter + self.claudeAttributionFilter = claudeAttributionFilter + self.cliProxyAPIHome = cliProxyAPIHome self.forceRescan = forceRescan } } @@ -768,6 +780,7 @@ enum CostUsageScanner { let output: Int let costNanos: Int let costPriced: Bool? + let attribution: CostUsageAttribution? } static func loadDailyReport( diff --git a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift new file mode 100644 index 0000000000..9bdf52d4c3 --- /dev/null +++ b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift @@ -0,0 +1,388 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CLIProxyAPIAttributionResolverTests { + @Test + func `request log confirms route without guessing upstream`() { + let timestamp = Date(timeIntervalSince1970: 1_784_179_200) + let resolver = CLIProxyAPIAttributionResolver( + observations: [ + .init(sessionID: "session-1", model: "gpt-5.6-sol", timestamp: timestamp), + ]) + + let attribution = resolver.attribution( + model: "gpt-5.6-sol", + modelProvider: .openAI, + sessionID: "session-1", + timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), + tokens: Self.tokens) + + #expect(attribution.route == .cliProxyAPI) + #expect(attribution.upstream == nil) + #expect(attribution.evidence == [.cliProxyRequestLog, .modelProvider]) + } + + @Test + func `request log confirms the entire claude code session route`() { + let timestamp = Date(timeIntervalSince1970: 1_784_179_200) + let resolver = CLIProxyAPIAttributionResolver( + observations: [ + .init(sessionID: "session-1", model: "gpt-5.6-sol", timestamp: timestamp), + ]) + + let attribution = resolver.attribution( + model: "gpt-5.6-sol", + modelProvider: .openAI, + sessionID: "session-1", + timestampUnixMs: Int64(timestamp.addingTimeInterval(3 * 60 * 60).timeIntervalSince1970 * 1000), + tokens: Self.tokens) + + #expect(attribution.route == .cliProxyAPI) + #expect(attribution.upstream == nil) + #expect(attribution.evidence == [.cliProxyRequestLog, .modelProvider]) + } + + @Test + func `codex auth inventory restores upstream after local proxy use is proven`() { + let resolver = CLIProxyAPIAttributionResolver( + observations: [ + .init(sessionID: "logged-session", model: "gpt-5.6-sol", timestamp: nil), + ], + authProviders: [ + .init(provider: "codex", authType: .oauth), + ]) + + let attribution = resolver.attribution( + model: "gpt-5.6-sol", + modelProvider: .openAI, + sessionID: "historical-session", + timestampUnixMs: nil, + tokens: Self.tokens) + + #expect(attribution.route == .cliProxyAPI) + #expect(attribution.upstream == .init( + provider: "codex", + authType: .oauth, + model: "gpt-5.6-sol")) + #expect(attribution.evidence == [ + .cliProxyAuthInventory, + .cliProxyRequestLog, + .modelProvider, + ]) + } + + @Test + func `request telemetry identifies exact codex oauth upstream`() { + let timestamp = Date(timeIntervalSince1970: 1_784_179_200) + let resolver = CLIProxyAPIAttributionResolver( + observations: [ + .init(sessionID: "session-1", model: "gpt-5.6-sol", timestamp: timestamp), + ], + usageRecords: [ + Self.record( + timestamp: timestamp.addingTimeInterval(1), + provider: "codex", + authType: "oauth"), + ]) + + let attribution = resolver.attribution( + model: "gpt-5.6-sol", + modelProvider: .openAI, + sessionID: "session-1", + timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), + tokens: Self.tokens) + + #expect(attribution.route == .cliProxyAPI) + #expect(attribution.upstream?.provider == "codex") + #expect(attribution.upstream?.authType == .oauth) + #expect(attribution.upstream?.model == "gpt-5.6-sol") + #expect(attribution.evidence == [ + .cliProxyRequestLog, + .cliProxyUsageTelemetry, + .modelProvider, + ]) + } + + @Test + func `request telemetry preserves api key authentication type`() { + let timestamp = Date(timeIntervalSince1970: 1_784_179_200) + let resolver = CLIProxyAPIAttributionResolver( + observations: [ + .init(sessionID: "session-1", model: "gpt-5.6-sol", timestamp: timestamp), + ], + usageRecords: [ + Self.record(timestamp: timestamp, provider: "openrouter", authType: "apikey"), + ]) + + let attribution = resolver.attribution( + model: "gpt-5.6-sol", + modelProvider: .openAI, + sessionID: "session-1", + timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), + tokens: Self.tokens) + + #expect(attribution.upstream?.provider == "openrouter") + #expect(attribution.upstream?.authType == .apiKey) + #expect(attribution.upstream?.displayName == "OpenRouter API key") + } + + @Test + func `ambiguous telemetry does not claim an upstream`() { + let timestamp = Date(timeIntervalSince1970: 1_784_179_200) + let resolver = CLIProxyAPIAttributionResolver( + observations: [ + .init(sessionID: "session-1", model: "gpt-5.6-sol", timestamp: timestamp), + ], + usageRecords: [ + Self.record(timestamp: timestamp, provider: "codex", authType: "oauth"), + Self.record(timestamp: timestamp, provider: "openrouter", authType: "api_key"), + ]) + + let attribution = resolver.attribution( + model: "gpt-5.6-sol", + modelProvider: .openAI, + sessionID: "session-1", + timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), + tokens: Self.tokens) + + #expect(attribution.route == .cliProxyAPI) + #expect(attribution.upstream == nil) + #expect(!attribution.evidence.contains(.cliProxyUsageTelemetry)) + } + + @Test + func `failed and token mismatched telemetry are ignored`() { + let timestamp = Date(timeIntervalSince1970: 1_784_179_200) + let resolver = CLIProxyAPIAttributionResolver( + observations: [ + .init(sessionID: "session-1", model: "gpt-5.6-sol", timestamp: timestamp), + ], + usageRecords: [ + Self.record( + timestamp: timestamp, + provider: "codex", + authType: "oauth", + failed: true), + CLIProxyAPIUsageRecord( + timestamp: timestamp.addingTimeInterval(1), + provider: "codex", + model: "gpt-5.6-sol", + alias: "gpt-5.6-sol", + endpoint: "POST /v1/messages", + authType: "oauth", + requestID: "request-mismatch", + tokens: .init(input: 999, output: 999, total: 1998)), + ]) + + let attribution = resolver.attribution( + model: "gpt-5.6-sol", + modelProvider: .openAI, + sessionID: "session-1", + timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), + tokens: Self.tokens) + + #expect(attribution.route == .cliProxyAPI) + #expect(attribution.upstream == nil) + } + + @Test + func `model without correlated request does not claim cliproxyapi`() { + let resolver = CLIProxyAPIAttributionResolver( + observations: [ + .init(sessionID: "other-session", model: "gpt-5.6-sol", timestamp: nil), + ], + usageRecords: [ + Self.record(timestamp: Date(), provider: "codex", authType: "oauth"), + ]) + + let attribution = resolver.attribution( + model: "gpt-5.6-sol", + modelProvider: .openAI, + sessionID: "session-1", + timestampUnixMs: nil, + tokens: Self.tokens) + + #expect(attribution.route == .unknown) + #expect(attribution.upstream == nil) + #expect(attribution.evidence == [.modelProvider]) + } + + @Test + func `filesystem loader correlates sanitized cached telemetry`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-attribution-\(UUID().uuidString)", isDirectory: true) + let home = root.appendingPathComponent("home", isDirectory: true) + let logs = home.appendingPathComponent("logs", isDirectory: true) + let cacheRoot = root.appendingPathComponent("cache", isDirectory: true) + try fileManager.createDirectory(at: logs, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let requestLog = """ + === REQUEST INFO === + URL: /v1/messages + Method: POST + Timestamp: 2026-07-16T12:00:00Z + === HEADERS === + X-Claude-Code-Session-Id: session-1 + === REQUEST BODY === + {"model":"gpt-5.6-sol"} + === RESPONSE === + Status: 200 + """ + try Data(requestLog.utf8).write(to: logs.appendingPathComponent("v1-messages.log")) + let timestamp = try #require(CostUsageDateParser.parse("2026-07-16T12:00:01Z")) + CLIProxyAPIUsageCacheIO.merge( + [Self.record(timestamp: timestamp, provider: "codex", authType: "oauth")], + cacheRoot: cacheRoot, + now: timestamp) + + let resolver = CLIProxyAPIAttributionResolver.load( + home: home, + cacheRoot: cacheRoot, + fileManager: fileManager) + let attribution = resolver.attribution( + model: "gpt-5.6-sol", + modelProvider: .openAI, + sessionID: "session-1", + timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), + tokens: Self.tokens) + + #expect(attribution.route == .cliProxyAPI) + #expect(attribution.upstream?.isCodex == true) + #expect(attribution.evidence.contains(.cliProxyUsageTelemetry)) + } + + @Test + func `usage cache never persists source or api key fields`() throws { + let fileManager = FileManager.default + let cacheRoot = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-cache-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: cacheRoot) } + let payload = """ + [{ + "timestamp":"2026-07-16T12:00:00Z", + "source":"private@example.com", + "api_key":"secret-client-key", + "provider":"codex", + "executor_type":"CodexExecutor", + "model":"gpt-5.6-sol", + "alias":"gpt-5.6-sol", + "endpoint":"POST /v1/messages", + "auth_type":"oauth", + "request_id":"request-1", + "failed":false, + "generate":true, + "tokens":{ + "input_tokens":10, + "output_tokens":20, + "cache_read_tokens":30, + "cache_creation_tokens":40, + "total_tokens":100 + } + }] + """ + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let records = try decoder.decode([CLIProxyAPIUsageRecord].self, from: Data(payload.utf8)) + let now = try #require(CostUsageDateParser.parse("2026-07-16T12:00:01Z")) + + #expect(CLIProxyAPIUsageCacheIO.merge(records, cacheRoot: cacheRoot, now: now) == 1) + let persisted = try String( + contentsOf: CLIProxyAPIUsageCacheIO.cacheFileURL(cacheRoot: cacheRoot), + encoding: .utf8) + #expect(!persisted.contains("private@example.com")) + #expect(!persisted.contains("secret-client-key")) + #expect(persisted.contains("\"provider\":\"codex\"")) + } + + @Test + func `usage queue client authenticates and decodes sanitized records`() async throws { + let responseBody = """ + [{ + "timestamp":"2026-07-16T12:00:00.123456789Z", + "source":"private@example.com", + "api_key":"secret-client-key", + "provider":"codex", + "executor_type":"CodexExecutor", + "model":"gpt-5.6-sol", + "alias":"gpt-5.6-sol", + "endpoint":"POST /v1/messages", + "auth_type":"oauth", + "request_id":"request-1", + "failed":false, + "generate":true, + "tokens":{"input_tokens":10,"output_tokens":20,"total_tokens":30} + }] + """ + let client = CLIProxyAPIUsageQueueClient( + settings: .init(managementKey: "management-secret"), + dataLoader: { request in + #expect(request.url?.absoluteString == + "http://127.0.0.1:8317/v0/management/usage-queue?count=100") + #expect(request.value(forHTTPHeaderField: "Authorization") == + "Bearer management-secret") + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + return (Data(responseBody.utf8), response) + }) + + let records = try await client.pop(count: 100) + + #expect(records.count == 1) + #expect(records[0].provider == "codex") + #expect(records[0].authType == "oauth") + #expect(records[0].tokens.total == 30) + } + + @Test + func `plain http management url is limited to loopback`() { + #expect(CLIProxyAPIConnectionSettings( + baseURL: "http://127.0.0.1:8317", + managementKey: "secret").isConfigured) + #expect(CLIProxyAPIConnectionSettings( + baseURL: "http://localhost:8317", + managementKey: "secret").isConfigured) + #expect(!CLIProxyAPIConnectionSettings( + baseURL: "http://192.168.1.10:8317", + managementKey: "secret").isConfigured) + #expect(CLIProxyAPIConnectionSettings( + baseURL: "https://proxy.example.com", + managementKey: "secret").isConfigured) + } + + private static let tokens = CLIProxyAPIAttributionResolver.TokenSignature( + input: 10, + cacheRead: 30, + cacheCreate: 40, + output: 20) + + private static func record( + timestamp: Date, + provider: String, + authType: String, + failed: Bool = false) -> CLIProxyAPIUsageRecord + { + CLIProxyAPIUsageRecord( + timestamp: timestamp, + provider: provider, + executorType: provider == "codex" ? "CodexExecutor" : "OpenAICompatExecutor", + model: "gpt-5.6-sol", + alias: "gpt-5.6-sol", + endpoint: "POST /v1/messages", + authType: authType, + requestID: "request-\(provider)-\(authType)-\(timestamp.timeIntervalSince1970)", + failed: failed, + tokens: .init( + input: 10, + output: 20, + cacheRead: 30, + cacheCreation: 40, + total: 100)) + } +} diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index 5eee6fdc93..1fbbdc09ec 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -12,8 +12,8 @@ struct CostUsageCacheTests { let vertexURL = CostUsageCacheIO.cacheFileURL(provider: .vertexai, cacheRoot: root) #expect(codexURL.lastPathComponent == "codex-v10.json") - #expect(claudeURL.lastPathComponent == "claude-v5.json") - #expect(vertexURL.lastPathComponent == "vertexai-v5.json") + #expect(claudeURL.lastPathComponent == "claude-v6.json") + #expect(vertexURL.lastPathComponent == "vertexai-v6.json") } @Test diff --git a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift index e7a42293c9..8d13ffa347 100644 --- a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift +++ b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift @@ -3,6 +3,56 @@ import Testing @testable import CodexBarCore struct CostUsageDailyReportMergeTests { + @Test + func `merged report keeps native and claude code proxy model rows distinct`() throws { + let native = CostUsageDailyReport( + data: [ + CostUsageDailyReport.Entry( + date: "2026-07-24", + inputTokens: 100, + outputTokens: 10, + totalTokens: 110, + costUSD: 1, + modelsUsed: ["gpt-5.6-sol"], + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.6-sol", + costUSD: 1, + totalTokens: 110), + ]), + ], + summary: nil) + let proxyAttribution = CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .openAI, + upstream: .init(provider: "codex", authType: .oauth), + evidence: [.cliProxyRequestLog, .cliProxyUsageTelemetry, .modelProvider]) + let proxy = CostUsageDailyReport( + data: [ + CostUsageDailyReport.Entry( + date: "2026-07-24", + inputTokens: 50, + outputTokens: 5, + totalTokens: 55, + costUSD: 0.5, + modelsUsed: ["gpt-5.6-sol"], + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.6-sol", + costUSD: 0.5, + totalTokens: 55, + attribution: proxyAttribution), + ]), + ], + summary: nil) + + let breakdowns = try #require(native.merged(with: proxy).data.first?.modelBreakdowns) + #expect(breakdowns.count == 2) + #expect(breakdowns.first { $0.attribution == nil }?.totalTokens == 110) + #expect(breakdowns.first { $0.attribution == proxyAttribution }?.totalTokens == 55) + } + @Test func `merged report sums overlapping day totals and model breakdowns`() { let native = CostUsageDailyReport( diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index 5db8108693..2fb815e4d3 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -709,6 +709,360 @@ extension CostUsageFetcherTests { ]) } + @Test + func `claude code proxy usage belongs to codex and keeps route attribution`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 24) + let proxyAssistant: [String: Any] = [ + "type": "assistant", + "timestamp": env.isoString(for: day), + "sessionId": "session-proxy", + "requestId": "request-proxy", + "message": [ + "id": "message-proxy", + "model": "gpt-5.6-sol", + "usage": [ + "input_tokens": 100, + "cache_creation_input_tokens": 10, + "cache_read_input_tokens": 20, + "output_tokens": 5, + ], + ], + ] + let nativeClaudeAssistant: [String: Any] = [ + "type": "assistant", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "requestId": "request-claude", + "message": [ + "id": "message-claude", + "model": "claude-sonnet-4-6", + "usage": [ + "input_tokens": 50, + "cache_creation_input_tokens": 5, + "cache_read_input_tokens": 5, + "output_tokens": 10, + ], + ], + ] + _ = try env.writeClaudeProjectFile( + relativePath: "proxy/session.jsonl", + contents: env.jsonl([proxyAssistant, nativeClaudeAssistant])) + let cliProxyHome = env.root.appendingPathComponent("cli-proxy-api", isDirectory: true) + let cliProxyLogs = cliProxyHome.appendingPathComponent("logs", isDirectory: true) + try FileManager.default.createDirectory(at: cliProxyLogs, withIntermediateDirectories: true) + try Data(#"{"type":"codex","access_token":"must-not-be-exposed"}"#.utf8) + .write(to: cliProxyHome.appendingPathComponent("codex-auth.json")) + let proxyLog = """ + === REQUEST INFO === + URL: /v1/messages + Timestamp: \(env.isoString(for: day)) + === HEADERS === + X-Claude-Code-Session-Id: session-proxy + === REQUEST BODY === + {"model":"gpt-5.6-sol"} + === API RESPONSE === + """ + try Data(proxyLog.utf8).write(to: cliProxyLogs.appendingPathComponent("request.log")) + CLIProxyAPIUsageCacheIO.merge( + [ + CLIProxyAPIUsageRecord( + timestamp: day, + provider: "codex", + executorType: "CodexExecutor", + model: "gpt-5.6-sol", + alias: "gpt-5.6-sol", + endpoint: "/v1/messages", + authType: "oauth", + requestID: "cliproxy-request-proxy", + tokens: .init( + input: 100, + output: 5, + cacheRead: 20, + cacheCreation: 10, + total: 135)), + ], + cacheRoot: env.cacheRoot, + now: day) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot, + cliProxyAPIHome: cliProxyHome) + let codex = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + allowPricingRefresh: false, + includePiSessions: false, + scannerOptions: options) + let claude = try await CostUsageFetcher.loadTokenSnapshot( + provider: .claude, + now: day, + allowPricingRefresh: false, + includePiSessions: false, + scannerOptions: options) + let cachedCodex = try #require(await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + scannerOptions: options)) + + let expectedCodexCost = try #require(CostUsagePricing.claudeProxyCodexCostUSD( + model: "gpt-5.6-sol", + inputTokens: 100, + cacheReadInputTokens: 20, + cacheCreationInputTokens: 10, + outputTokens: 5)) + let codexBreakdown = try #require(codex.daily.first?.modelBreakdowns?.first) + #expect(codex.daily.first?.totalTokens == 135) + #expect(abs((codex.daily.first?.costUSD ?? 0) - expectedCodexCost) < 0.000001) + #expect(codexBreakdown.modelName == "gpt-5.6-sol") + #expect(codexBreakdown.attribution == CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .openAI, + upstream: .init( + provider: "codex", + authType: .oauth, + model: "gpt-5.6-sol", + executorType: "CodexExecutor"), + evidence: [.cliProxyRequestLog, .cliProxyUsageTelemetry, .modelProvider])) + #expect(codex.projects.map(\.name) == ["Claude Code via CLIProxyAPI"]) + + #expect(claude.daily.first?.totalTokens == 70) + #expect(claude.daily.first?.modelsUsed == ["claude-sonnet-4-6"]) + #expect(claude.daily.first?.modelBreakdowns?.first?.attribution == nil) + + #expect(cachedCodex.daily.first?.totalTokens == 135) + #expect(cachedCodex.daily.first?.modelBreakdowns?.first?.attribution == codexBreakdown.attribution) + } + + @Test + func `openai model without proxy evidence stays out of codex totals`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 24) + _ = try env.writeClaudeProjectFile( + relativePath: "unresolved/session.jsonl", + contents: env.jsonl([[ + "type": "assistant", + "timestamp": env.isoString(for: day), + "sessionId": "unmatched-session", + "requestId": "request-unresolved", + "message": [ + "id": "message-unresolved", + "model": "gpt-5.6-sol", + "usage": ["input_tokens": 100, "output_tokens": 5], + ], + ]])) + let cliProxyHome = env.root.appendingPathComponent("cli-proxy-api", isDirectory: true) + try FileManager.default.createDirectory( + at: cliProxyHome.appendingPathComponent("logs", isDirectory: true), + withIntermediateDirectories: true) + try Data(#"{"type":"codex"}"#.utf8) + .write(to: cliProxyHome.appendingPathComponent("codex-auth.json")) + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot, + cliProxyAPIHome: cliProxyHome) + + let codex = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + allowPricingRefresh: false, + includePiSessions: false, + scannerOptions: options) + let claude = try await CostUsageFetcher.loadTokenSnapshot( + provider: .claude, + now: day, + allowPricingRefresh: false, + includePiSessions: false, + scannerOptions: options) + + #expect(codex.daily.isEmpty) + let attribution = try #require(claude.daily.first?.modelBreakdowns?.first?.attribution) + #expect(attribution.modelProvider == .openAI) + #expect(attribution.route == .unknown) + #expect(attribution.upstream == nil) + } + + @Test + func `cliproxy codex inventory keeps a session stable beyond the request log window`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 24) + func assistant(seconds: TimeInterval, requestID: String) -> [String: Any] { + [ + "type": "assistant", + "timestamp": env.isoString(for: day.addingTimeInterval(seconds)), + "sessionId": "session-proxy", + "requestId": requestID, + "message": [ + "id": "message-\(requestID)", + "model": "gpt-5.6-sol", + "usage": ["input_tokens": 100, "output_tokens": 5], + ], + ] + } + _ = try env.writeClaudeProjectFile( + relativePath: "stable-proxy/session.jsonl", + contents: env.jsonl([ + assistant(seconds: 0, requestID: "first"), + assistant(seconds: 3 * 60 * 60, requestID: "second"), + ])) + let cliProxyHome = env.root.appendingPathComponent("cli-proxy-api", isDirectory: true) + let cliProxyLogs = cliProxyHome.appendingPathComponent("logs", isDirectory: true) + try FileManager.default.createDirectory(at: cliProxyLogs, withIntermediateDirectories: true) + try Data(#"{"type":"codex","disabled":false,"access_token":"must-not-be-exposed"}"#.utf8) + .write(to: cliProxyHome.appendingPathComponent("codex-auth.json")) + let proxyLog = """ + === REQUEST INFO === + URL: /v1/messages + Timestamp: \(env.isoString(for: day)) + === HEADERS === + X-Claude-Code-Session-Id: session-proxy + === REQUEST BODY === + {"model":"gpt-5.6-sol"} + === API RESPONSE === + """ + try Data(proxyLog.utf8).write(to: cliProxyLogs.appendingPathComponent("request.log")) + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot, + cliProxyAPIHome: cliProxyHome) + + let codex = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + allowPricingRefresh: false, + includePiSessions: false, + scannerOptions: options) + let claude = try await CostUsageFetcher.loadTokenSnapshot( + provider: .claude, + now: day, + allowPricingRefresh: false, + includePiSessions: false, + scannerOptions: options) + + let breakdown = try #require(codex.daily.first?.modelBreakdowns?.first) + #expect(codex.daily.first?.totalTokens == 210) + #expect(codex.daily.first?.modelBreakdowns?.count == 1) + #expect(breakdown.modelName == "gpt-5.6-sol") + #expect(breakdown.attribution?.route == .cliProxyAPI) + #expect(breakdown.attribution?.upstream == .init( + provider: "codex", + authType: .oauth, + model: "gpt-5.6-sol")) + #expect(breakdown.attribution?.evidence.contains(.cliProxyAuthInventory) == true) + #expect(claude.daily.isEmpty) + } + + @Test + func `claude report preserves non codex proxy backend attribution`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 24) + func assistant(sessionID: String, requestID: String, model: String, seconds: TimeInterval) -> [String: Any] { + [ + "type": "assistant", + "timestamp": env.isoString(for: day.addingTimeInterval(seconds)), + "sessionId": sessionID, + "requestId": requestID, + "message": [ + "id": "message-\(requestID)", + "model": model, + "usage": ["input_tokens": 10, "output_tokens": 2], + ], + ] + } + _ = try env.writeClaudeProjectFile( + relativePath: "multi-backend/session.jsonl", + contents: env.jsonl([ + assistant(sessionID: "session-gemini", requestID: "gemini", model: "gemini-3-pro", seconds: 0), + assistant( + sessionID: "session-claude", + requestID: "claude", + model: "claude-sonnet-4-6", + seconds: 1), + ])) + + let cliProxyHome = env.root.appendingPathComponent("cli-proxy-api", isDirectory: true) + let logs = cliProxyHome.appendingPathComponent("logs", isDirectory: true) + try FileManager.default.createDirectory(at: logs, withIntermediateDirectories: true) + try Data(#"{"type":"gemini"}"#.utf8) + .write(to: cliProxyHome.appendingPathComponent("gemini-auth.json")) + try Data(#"{"type":"claude"}"#.utf8) + .write(to: cliProxyHome.appendingPathComponent("claude-auth.json")) + for (name, sessionID, model, seconds) in [ + ("gemini", "session-gemini", "gemini-3-pro", 0.0), + ("claude", "session-claude", "claude-sonnet-4-6", 1.0), + ] { + let log = """ + === REQUEST INFO === + URL: /v1/messages + Timestamp: \(env.isoString(for: day.addingTimeInterval(seconds))) + === HEADERS === + X-Claude-Code-Session-Id: \(sessionID) + === REQUEST BODY === + {"model":"\(model)"} + === API RESPONSE === + """ + try Data(log.utf8).write(to: logs.appendingPathComponent("\(name).log")) + } + CLIProxyAPIUsageCacheIO.merge( + [ + CLIProxyAPIUsageRecord( + timestamp: day, + provider: "gemini", + executorType: "GeminiExecutor", + model: "gemini-3-pro", + alias: "gemini-3-pro", + endpoint: "/v1/messages", + authType: "oauth", + requestID: "cliproxy-gemini", + tokens: .init(input: 10, output: 2, total: 12)), + CLIProxyAPIUsageRecord( + timestamp: day.addingTimeInterval(1), + provider: "claude", + executorType: "ClaudeExecutor", + model: "claude-sonnet-4-6", + alias: "claude-sonnet-4-6", + endpoint: "/v1/messages", + authType: "oauth", + requestID: "cliproxy-claude", + tokens: .init(input: 10, output: 2, total: 12)), + ], + cacheRoot: env.cacheRoot, + now: day.addingTimeInterval(1)) + + let options = CostUsageScanner.Options( + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot, + cliProxyAPIHome: cliProxyHome) + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .claude, + now: day, + allowPricingRefresh: false, + includePiSessions: false, + scannerOptions: options) + + let breakdowns = try #require(snapshot.daily.first?.modelBreakdowns) + let claude = try #require(breakdowns.first { $0.modelName == "claude-sonnet-4-6" }) + let gemini = try #require(breakdowns.first { $0.modelName == "gemini-3-pro" }) + #expect(claude.attribution?.route == .cliProxyAPI) + #expect(claude.attribution?.upstream?.provider == "claude") + #expect(claude.attribution?.upstream?.authType == .oauth) + #expect(gemini.attribution?.route == .cliProxyAPI) + #expect(gemini.attribution?.upstream?.provider == "gemini") + #expect(gemini.attribution?.upstream?.authType == .oauth) + #expect(gemini.costUSD == nil) + } + @Test func `fetcher prefers turn context model over token count fallback`() async throws { let env = try CostUsageTestEnvironment() diff --git a/Tests/CodexBarTests/SpendDashboardControllerTests.swift b/Tests/CodexBarTests/SpendDashboardControllerTests.swift index 17e398ce42..7260e9e142 100644 --- a/Tests/CodexBarTests/SpendDashboardControllerTests.swift +++ b/Tests/CodexBarTests/SpendDashboardControllerTests.swift @@ -288,7 +288,7 @@ struct SpendDashboardControllerTests { let snapshot = Self.input(id: "claude", provider: .claude, cost: 3).snapshot store._setTokenSnapshotForTesting(snapshot, provider: .claude) store._test_tokenUsageRefreshOverride = { _, _ in } - let controller = SpendDashboardController(requestBuilder: { mode in + let controller = SpendDashboardController(userDefaults: settings.userDefaults, requestBuilder: { mode in await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) }) @@ -408,7 +408,7 @@ struct SpendDashboardControllerTests { environmentBase: [:]) store._setTokenSnapshotForTesting(Self.input(provider: .claude, cost: 3).snapshot, provider: .claude) store._test_tokenUsageRefreshOverride = { _, _ in } - let controller = SpendDashboardController(requestBuilder: { mode in + let controller = SpendDashboardController(userDefaults: settings.userDefaults, requestBuilder: { mode in await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) }) @@ -431,7 +431,7 @@ struct SpendDashboardControllerTests { #expect(controller.failedSourceCount == 1) #expect(store.tokenSnapshot(for: .claude)?.last30DaysCostUSD == 3) - let reopenedController = SpendDashboardController(requestBuilder: { mode in + let reopenedController = SpendDashboardController(userDefaults: settings.userDefaults, requestBuilder: { mode in await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) }) reopenedController.update(configuration: replacementConfiguration) @@ -487,7 +487,7 @@ struct SpendDashboardControllerTests { store._setTokenSnapshotForTesting(Self.input(provider: .mistral, cost: 3).snapshot, provider: .mistral) store._test_providerRefreshOverride = { _ in } - let controller = SpendDashboardController(requestBuilder: { mode in + let controller = SpendDashboardController(userDefaults: settings.userDefaults, requestBuilder: { mode in await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) }) controller.update(configuration: selectedBackupConfiguration) @@ -527,7 +527,7 @@ struct SpendDashboardControllerTests { environmentBase: [:]) store._setTokenSnapshotForTesting(Self.input(provider: .claude, cost: 4).snapshot, provider: .claude) store._test_tokenUsageRefreshOverride = { _, _ in } - let controller = SpendDashboardController(requestBuilder: { mode in + let controller = SpendDashboardController(userDefaults: settings.userDefaults, requestBuilder: { mode in await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) }) controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) @@ -557,7 +557,7 @@ struct SpendDashboardControllerTests { environmentBase: [:]) store._setTokenSnapshotForTesting(Self.input(provider: .claude, cost: 5).snapshot, provider: .claude) store._test_tokenUsageRefreshOverride = { _, _ in } - let controller = SpendDashboardController(requestBuilder: { mode in + let controller = SpendDashboardController(userDefaults: settings.userDefaults, requestBuilder: { mode in await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) }) let firstConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) @@ -626,7 +626,7 @@ struct SpendDashboardControllerTests { environmentBase: [:]) store._setTokenSnapshotForTesting(Self.input(provider: .claude, cost: 5).snapshot, provider: .claude) store._test_tokenUsageRefreshOverride = { _, _ in } - let controller = SpendDashboardController(requestBuilder: { mode in + let controller = SpendDashboardController(userDefaults: settings.userDefaults, requestBuilder: { mode in await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) }) controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) @@ -644,7 +644,7 @@ struct SpendDashboardControllerTests { #expect(controller.model.groups.isEmpty) #expect(controller.failedSourceCount == 1) - let reopenedController = SpendDashboardController(requestBuilder: { mode in + let reopenedController = SpendDashboardController(userDefaults: settings.userDefaults, requestBuilder: { mode in await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) }) reopenedController.update(configuration: reenabledConfiguration) diff --git a/Tests/CodexBarTests/SpendDashboardProxyAttributionTests.swift b/Tests/CodexBarTests/SpendDashboardProxyAttributionTests.swift new file mode 100644 index 0000000000..7f7f1f8af1 --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardProxyAttributionTests.swift @@ -0,0 +1,84 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct SpendDashboardProxyAttributionTests { + @Test + func `unresolved route describes known facts without an unknown warning`() { + let attribution = CostUsageAttribution( + client: .claudeCode, + route: .unknown, + modelProvider: .openAI, + evidence: [.modelProvider]) + + #expect(spendDashboardModelSourceText( + providerName: "Claude", + attribution: attribution) == "Claude · OpenAI model via Claude Code") + } + + @Test + func `confirmed proxy route without upstream telemetry does not infer a backend`() { + let attribution = CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .openAI, + evidence: [.cliProxyRequestLog, .modelProvider]) + + #expect(spendDashboardModelSourceText( + providerName: "Claude", + attribution: attribution) == "CLIProxyAPI via Claude Code") + } + + @Test + func `proxy attribution survives dashboard aggregation and describes the route`() throws { + let attribution = CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .openAI, + upstream: .init(provider: "codex", authType: .oauth), + evidence: [.cliProxyRequestLog, .cliProxyUsageTelemetry, .modelProvider]) + let entry = CostUsageDailyReport.Entry( + date: "2026-07-16", + inputTokens: 90, + outputTokens: 10, + totalTokens: 100, + costUSD: 1, + modelsUsed: ["gpt-5.6-sol"], + modelBreakdowns: [ + .init( + modelName: "gpt-5.6-sol", + costUSD: 1, + totalTokens: 100, + attribution: attribution), + ]) + let now = Date(timeIntervalSince1970: 1_784_179_200) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 100, + sessionCostUSD: 1, + last30DaysTokens: 100, + last30DaysCostUSD: 1, + daily: [entry], + updatedAt: now) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + + let model = SpendDashboardModel.build( + inputs: [ + .init( + provider: .codex, + displayName: "Codex", + snapshot: snapshot), + ], + requestedDays: 7, + now: now, + calendar: calendar) + + let row = try #require(model.groups.first?.models.first) + #expect(row.provider == .codex) + #expect(row.attribution == attribution) + #expect(spendDashboardModelSourceText( + providerName: row.providerName, + attribution: row.attribution) == "Codex OAuth · CLIProxyAPI via Claude Code") + } +} diff --git a/Tests/CodexBarTests/SpendDashboardTokenProvenanceTests.swift b/Tests/CodexBarTests/SpendDashboardTokenProvenanceTests.swift index 88e594eca4..a31bf1ae40 100644 --- a/Tests/CodexBarTests/SpendDashboardTokenProvenanceTests.swift +++ b/Tests/CodexBarTests/SpendDashboardTokenProvenanceTests.swift @@ -124,7 +124,7 @@ struct SpendDashboardTokenProvenanceTests { store.activateCachedTokenAccountSnapshot(provider: .mistral, accountID: account.id) #expect(store.tokenSnapshotPublicationRevision(for: .mistral) == baselineRevision) store._test_providerRefreshOverride = { _ in } - let controller = SpendDashboardController(requestBuilder: { mode in + let controller = SpendDashboardController(userDefaults: settings.userDefaults, requestBuilder: { mode in await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) }) controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) @@ -148,7 +148,7 @@ struct SpendDashboardTokenProvenanceTests { return loadCount == 1 ? Self.tokenSnapshot(cost: 4) : Self.emptyTokenSnapshot() } await store.refreshTokenUsageNow(for: .bedrock, force: true) - let controller = SpendDashboardController(requestBuilder: { mode in + let controller = SpendDashboardController(userDefaults: settings.userDefaults, requestBuilder: { mode in await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) }) controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) @@ -177,7 +177,7 @@ struct SpendDashboardTokenProvenanceTests { } await store.refreshTokenUsageNow(for: .bedrock, force: true) let publicationRevision = store.tokenSnapshotPublicationRevision(for: .bedrock) - let controller = SpendDashboardController(requestBuilder: { mode in + let controller = SpendDashboardController(userDefaults: settings.userDefaults, requestBuilder: { mode in await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) }) From 5d61c301a595560b9102a5bc6b402fc2c5f05605 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sat, 25 Jul 2026 17:13:24 -0700 Subject: [PATCH 002/183] Tighten proxy attribution evidence --- .../CLIProxyAPIAttributionResolver.swift | 2 +- .../CostUsage/CostUsageScanner+Claude.swift | 12 +++++---- .../CLIProxyAPIAttributionResolverTests.swift | 26 +++++++++++++++++-- .../CodexBarTests/CostUsageFetcherTests.swift | 8 ++++++ 4 files changed, 40 insertions(+), 8 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift index 295224cd5f..b68286c75b 100644 --- a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift +++ b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift @@ -77,7 +77,7 @@ struct CLIProxyAPIAttributionResolver: Sendable { model: model, tokens: tokens) } - let inventoryUpstream = usageRecord == nil + let inventoryUpstream = routeObservation != nil && usageRecord == nil ? self.authInventoryUpstream(model: model, modelProvider: modelProvider) : nil let routeConfirmed = routeObservation != nil || inventoryUpstream != nil diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 5cf7314633..4ec118c34c 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -868,12 +868,14 @@ extension CostUsageScanner { cacheRead: row.cacheRead, cacheCreate: row.cacheCreate, output: row.output)) - let attribution = if let liveAttribution { - liveAttribution.route == .cliProxyAPI || modelProvider != .anthropic - ? liveAttribution - : nil - } else { + let attribution: CostUsageAttribution? = if liveAttribution?.route == .cliProxyAPI { + liveAttribution + } else if row.attribution?.route == .cliProxyAPI { row.attribution + } else if modelProvider != .anthropic { + liveAttribution ?? row.attribution + } else { + nil } let isCodexBackend = attribution?.route == .cliProxyAPI && attribution?.upstream?.isCodex == true diff --git a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift index 9bdf52d4c3..0c52686ca6 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift @@ -44,7 +44,7 @@ struct CLIProxyAPIAttributionResolverTests { } @Test - func `codex auth inventory restores upstream after local proxy use is proven`() { + func `codex auth inventory identifies upstream after this session route is proven`() { let resolver = CLIProxyAPIAttributionResolver( observations: [ .init(sessionID: "logged-session", model: "gpt-5.6-sol", timestamp: nil), @@ -56,7 +56,7 @@ struct CLIProxyAPIAttributionResolverTests { let attribution = resolver.attribution( model: "gpt-5.6-sol", modelProvider: .openAI, - sessionID: "historical-session", + sessionID: "logged-session", timestampUnixMs: nil, tokens: Self.tokens) @@ -72,6 +72,28 @@ struct CLIProxyAPIAttributionResolverTests { ]) } + @Test + func `codex auth inventory does not transfer route proof between sessions`() { + let resolver = CLIProxyAPIAttributionResolver( + observations: [ + .init(sessionID: "logged-session", model: "gpt-5.6-sol", timestamp: nil), + ], + authProviders: [ + .init(provider: "codex", authType: .oauth), + ]) + + let attribution = resolver.attribution( + model: "gpt-5.6-sol", + modelProvider: .openAI, + sessionID: "unrelated-session", + timestampUnixMs: nil, + tokens: Self.tokens) + + #expect(attribution.route == .unknown) + #expect(attribution.upstream == nil) + #expect(attribution.evidence == [.modelProvider]) + } + @Test func `request telemetry identifies exact codex oauth upstream`() { let timestamp = Date(timeIntervalSince1970: 1_784_179_200) diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index 2fb815e4d3..4743cde02f 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -835,6 +835,14 @@ extension CostUsageFetcherTests { #expect(cachedCodex.daily.first?.totalTokens == 135) #expect(cachedCodex.daily.first?.modelBreakdowns?.first?.attribution == codexBreakdown.attribution) + + try FileManager.default.removeItem(at: cliProxyLogs) + try FileManager.default.removeItem(at: cliProxyHome.appendingPathComponent("codex-auth.json")) + let cachedAfterLogRotation = try #require(await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + scannerOptions: options)) + #expect(cachedAfterLogRotation.daily.first?.totalTokens == 135) + #expect(cachedAfterLogRotation.daily.first?.modelBreakdowns?.first?.attribution == codexBreakdown.attribution) } @Test From 023c0f1f4c8a744d632a9e34eb490b66358c6099 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sat, 25 Jul 2026 17:18:21 -0700 Subject: [PATCH 003/183] Fix Linux telemetry networking import --- Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 5f426e541d..a78c665e7b 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -1,4 +1,7 @@ import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif struct CLIProxyAPIUsageRecord: Codable, Equatable, Sendable { struct Tokens: Codable, Equatable, Sendable { From c3bedb05f844666a1f84e8e9ef55090230bee5ac Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sun, 26 Jul 2026 20:15:10 -0700 Subject: [PATCH 004/183] Fix CLIProxyAPI attribution edge cases --- Sources/CodexBar/SpendDashboardModel.swift | 20 ++--- .../CLIProxyAPIUsageTelemetry.swift | 50 ++++++++++++- Sources/CodexBarCore/CostUsageFetcher.swift | 30 +++++--- .../CLIProxyAPIAttributionResolverTests.swift | 75 +++++++++++++++++++ .../CodexBarTests/CostUsageFetcherTests.swift | 74 ++++++++++++++++++ .../SpendDashboardProxyAttributionTests.swift | 69 +++++++++++++++++ 6 files changed, 298 insertions(+), 20 deletions(-) diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index 956ed3629b..006661c9d2 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -35,6 +35,12 @@ struct SpendDashboardModel: Equatable, Sendable { } struct ModelRow: Identifiable, Equatable, Sendable { + struct ID: Hashable, Sendable { + let provider: UsageProvider + let modelName: String + let attribution: CostUsageAttribution? + } + let rank: Int let provider: UsageProvider let providerName: String @@ -61,15 +67,11 @@ struct SpendDashboardModel: Equatable, Sendable { self.attribution = attribution } - var id: String { - [ - self.provider.rawValue, - self.modelName, - self.attribution?.client.rawValue ?? "", - self.attribution?.route.rawValue ?? "", - self.attribution?.upstream?.provider ?? "", - self.attribution?.upstream?.authType.rawValue ?? "", - ].joined(separator: ":") + var id: ID { + ID( + provider: self.provider, + modelName: self.modelName, + attribution: self.attribution) } } diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index a78c665e7b..f090e26453 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -281,9 +281,40 @@ public enum CLIProxyAPIUsageCollectionResult: Equatable, Sendable { case failed(String) } +private actor CLIProxyAPIUsageCollectionGate { + private var isLocked = false + private var waiters: [CheckedContinuation] = [] + + func perform(_ operation: @Sendable () async -> T) async -> T { + await self.acquire() + let result = await operation() + self.release() + return result + } + + private func acquire() async { + if !self.isLocked { + self.isLocked = true + return + } + await withCheckedContinuation { continuation in + self.waiters.append(continuation) + } + } + + private func release() { + guard !self.waiters.isEmpty else { + self.isLocked = false + return + } + self.waiters.removeFirst().resume() + } +} + public enum CLIProxyAPIUsageCollector { private static let maximumBatches = 10 private static let batchSize = 100 + private static let collectionGate = CLIProxyAPIUsageCollectionGate() public static func collect( cacheRoot: URL? = nil, @@ -291,8 +322,25 @@ public enum CLIProxyAPIUsageCollector { -> CLIProxyAPIUsageCollectionResult { guard let settings, settings.isConfigured else { return .notConfigured } + return await self.collect( + cacheRoot: cacheRoot, + client: CLIProxyAPIUsageQueueClient(settings: settings)) + } + + static func collect( + cacheRoot: URL? = nil, + client: CLIProxyAPIUsageQueueClient) async -> CLIProxyAPIUsageCollectionResult + { + await self.collectionGate.perform { + await self.collectUnserialized(cacheRoot: cacheRoot, client: client) + } + } + + private static func collectUnserialized( + cacheRoot: URL?, + client: CLIProxyAPIUsageQueueClient) async -> CLIProxyAPIUsageCollectionResult + { do { - let client = CLIProxyAPIUsageQueueClient(settings: settings) var records: [CLIProxyAPIUsageRecord] = [] for _ in 0.. [CostUsageProjectBreakdown] { - var dailyByPath: [String: [CostUsageDailyReport]] = [:] - var namesByPath: [String: String] = [:] - var sourceDailyByProjectPath: [String: [String: [CostUsageDailyReport]]] = [:] - var sourceNamesByProjectPath: [String: [String: String]] = [:] + var dailyByPath: [ProjectMergeKey: [CostUsageDailyReport]] = [:] + var namesByPath: [ProjectMergeKey: String] = [:] + var sourceDailyByProjectPath: [ProjectMergeKey: [ProjectMergeKey: [CostUsageDailyReport]]] = [:] + var sourceNamesByProjectPath: [ProjectMergeKey: [ProjectMergeKey: String]] = [:] for project in projects { - let key = project.path ?? "" + let key = ProjectMergeKey(name: project.name, path: project.path) namesByPath[key] = project.name dailyByPath[key, default: []].append(CostUsageDailyReport(data: project.daily, summary: nil)) let sources = project.sources.isEmpty @@ -833,7 +833,7 @@ extension CostUsageFetcher { ] : project.sources for source in sources { - let sourceKey = source.path ?? "" + let sourceKey = ProjectMergeKey(name: source.name, path: source.path) sourceNamesByProjectPath[key, default: [:]][sourceKey] = source.name sourceDailyByProjectPath[key, default: [:]][sourceKey, default: []] .append(CostUsageDailyReport(data: source.daily, summary: nil)) @@ -843,7 +843,7 @@ extension CostUsageFetcher { let merged = CostUsageDailyReport.merged(reports) return CostUsageProjectBreakdown( name: namesByPath[key] ?? CostUsageProjectBreakdown.unknownProjectName, - path: key.isEmpty ? nil : key, + path: key.path, totalTokens: merged.summary?.totalTokens, totalCostUSD: merged.summary?.totalCostUSD, daily: merged.data, @@ -867,15 +867,25 @@ extension CostUsageFetcher { } } + private struct ProjectMergeKey: Hashable { + let path: String? + let syntheticName: String? + + init(name: String, path: String?) { + self.path = path + self.syntheticName = path == nil ? name : nil + } + } + private static func mergedProjectSources( - sourceDailyByPath: [String: [CostUsageDailyReport]], - sourceNamesByPath: [String: String]) -> [CostUsageProjectSourceBreakdown] + sourceDailyByPath: [ProjectMergeKey: [CostUsageDailyReport]], + sourceNamesByPath: [ProjectMergeKey: String]) -> [CostUsageProjectSourceBreakdown] { sourceDailyByPath.map { key, reports in let merged = CostUsageDailyReport.merged(reports) return CostUsageProjectSourceBreakdown( name: sourceNamesByPath[key] ?? CostUsageProjectBreakdown.unknownProjectName, - path: key.isEmpty ? nil : key, + path: key.path, totalTokens: merged.summary?.totalTokens, totalCostUSD: merged.summary?.totalCostUSD, daily: merged.data, diff --git a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift index 0c52686ca6..f2bf94bd07 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift @@ -362,6 +362,65 @@ struct CLIProxyAPIAttributionResolverTests { #expect(records[0].tokens.total == 30) } + @Test + func `usage collector serializes queue pops and cache merges`() async throws { + let fileManager = FileManager.default + let cacheRoot = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-collector-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: cacheRoot) } + let probe = CLIProxyAPICollectionConcurrencyProbe() + let timestamp = try #require(CostUsageDateParser.parse("2026-07-16T12:00:00Z")) + + func client(requestID: String, seconds: TimeInterval) throws -> CLIProxyAPIUsageQueueClient { + let record = CLIProxyAPIUsageRecord( + timestamp: timestamp.addingTimeInterval(seconds), + provider: "codex", + model: "gpt-5.6-sol", + alias: "gpt-5.6-sol", + endpoint: "POST /v1/messages", + authType: "oauth", + requestID: requestID, + tokens: .init(input: 10, output: 20, total: 30)) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode([record]) + return CLIProxyAPIUsageQueueClient( + settings: .init(managementKey: "management-secret"), + dataLoader: { request in + await probe.recordCall() + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + return (data, response) + }) + } + + let firstClient = try client(requestID: "request-1", seconds: 0) + let secondClient = try client(requestID: "request-2", seconds: 1) + let results = await withTaskGroup( + of: CLIProxyAPIUsageCollectionResult.self, + returning: [CLIProxyAPIUsageCollectionResult].self) + { group in + group.addTask { + await CLIProxyAPIUsageCollector.collect(cacheRoot: cacheRoot, client: firstClient) + } + group.addTask { + await CLIProxyAPIUsageCollector.collect(cacheRoot: cacheRoot, client: secondClient) + } + return await group.reduce(into: []) { $0.append($1) } + } + + #expect(results.allSatisfy { $0 == .collected(1) }) + #expect(await probe.maximumActiveCallCount() == 1) + #expect(Set(CLIProxyAPIUsageCacheIO.load(cacheRoot: cacheRoot).map(\.requestID)) == [ + "request-1", + "request-2", + ]) + } + @Test func `plain http management url is limited to loopback`() { #expect(CLIProxyAPIConnectionSettings( @@ -408,3 +467,19 @@ struct CLIProxyAPIAttributionResolverTests { total: 100)) } } + +private actor CLIProxyAPICollectionConcurrencyProbe { + private var activeCallCount = 0 + private var maximumActiveCount = 0 + + func recordCall() async { + self.activeCallCount += 1 + self.maximumActiveCount = max(self.maximumActiveCount, self.activeCallCount) + try? await Task.sleep(for: .milliseconds(100)) + self.activeCallCount -= 1 + } + + func maximumActiveCallCount() -> Int { + self.maximumActiveCount + } +} diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index 4743cde02f..2b73d5fd80 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -845,6 +845,80 @@ extension CostUsageFetcherTests { #expect(cachedAfterLogRotation.daily.first?.modelBreakdowns?.first?.attribution == codexBreakdown.attribution) } + @Test + func `proxy and pi usage keep distinct synthetic projects`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 24) + _ = try env.writeClaudeProjectFile( + relativePath: "proxy/session.jsonl", + contents: env.jsonl([[ + "type": "assistant", + "timestamp": env.isoString(for: day), + "sessionId": "session-proxy", + "requestId": "request-proxy", + "message": [ + "id": "message-proxy", + "model": "gpt-5.6-sol", + "usage": ["input_tokens": 100, "output_tokens": 5], + ], + ]])) + _ = try env.writePiSessionFile( + relativePath: "2026-07-24T10-00-00-000Z_pi.jsonl", + contents: env.jsonl([[ + "type": "message", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "openai/gpt-5.4", + "timestamp": Int(day.addingTimeInterval(1).timeIntervalSince1970 * 1000), + "usage": ["input": 50, "output": 5, "totalTokens": 55], + ], + ]])) + let cliProxyHome = env.root.appendingPathComponent("cli-proxy-api", isDirectory: true) + let cliProxyLogs = cliProxyHome.appendingPathComponent("logs", isDirectory: true) + try FileManager.default.createDirectory(at: cliProxyLogs, withIntermediateDirectories: true) + try Data(#"{"type":"codex"}"#.utf8) + .write(to: cliProxyHome.appendingPathComponent("codex-auth.json")) + let proxyLog = """ + === REQUEST INFO === + URL: /v1/messages + Timestamp: \(env.isoString(for: day)) + === HEADERS === + X-Claude-Code-Session-Id: session-proxy + === REQUEST BODY === + {"model":"gpt-5.6-sol"} + === API RESPONSE === + """ + try Data(proxyLog.utf8).write(to: cliProxyLogs.appendingPathComponent("request.log")) + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot, + cliProxyAPIHome: cliProxyHome) + let piOptions = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) + + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + allowPricingRefresh: false, + scannerOptions: options, + piScannerOptions: piOptions) + + #expect(snapshot.projects.count == 2) + #expect(Set(snapshot.projects.map(\.name)) == [ + "Claude Code via CLIProxyAPI", + CostUsageProjectBreakdown.unknownProjectName, + ]) + #expect(snapshot.projects.allSatisfy { $0.path == nil }) + #expect(snapshot.projects.allSatisfy { $0.sources.map(\.name) == [$0.name] }) + } + @Test func `openai model without proxy evidence stays out of codex totals`() async throws { let env = try CostUsageTestEnvironment() diff --git a/Tests/CodexBarTests/SpendDashboardProxyAttributionTests.swift b/Tests/CodexBarTests/SpendDashboardProxyAttributionTests.swift index 7f7f1f8af1..dfad26f70b 100644 --- a/Tests/CodexBarTests/SpendDashboardProxyAttributionTests.swift +++ b/Tests/CodexBarTests/SpendDashboardProxyAttributionTests.swift @@ -81,4 +81,73 @@ struct SpendDashboardProxyAttributionTests { providerName: row.providerName, attribution: row.attribution) == "Codex OAuth · CLIProxyAPI via Claude Code") } + + @Test + func `model row identity includes complete proxy attribution`() throws { + let inventoryAttribution = CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .openAI, + upstream: .init( + provider: "codex", + authType: .oauth, + model: "gpt-5.6-sol"), + evidence: [.cliProxyAuthInventory, .cliProxyRequestLog, .modelProvider]) + let telemetryAttribution = CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .openAI, + upstream: .init( + provider: "codex", + authType: .oauth, + model: "openai/gpt-5.6-sol", + executorType: "CodexExecutor"), + evidence: [.cliProxyRequestLog, .cliProxyUsageTelemetry, .modelProvider]) + let entry = CostUsageDailyReport.Entry( + date: "2026-07-16", + inputTokens: 100, + outputTokens: 100, + totalTokens: 200, + costUSD: 2, + modelsUsed: ["gpt-5.6-sol"], + modelBreakdowns: [ + .init( + modelName: "gpt-5.6-sol", + costUSD: 1, + totalTokens: 100, + attribution: inventoryAttribution), + .init( + modelName: "gpt-5.6-sol", + costUSD: 1, + totalTokens: 100, + attribution: telemetryAttribution), + ]) + let now = Date(timeIntervalSince1970: 1_784_179_200) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 200, + sessionCostUSD: 2, + last30DaysTokens: 200, + last30DaysCostUSD: 2, + daily: [entry], + updatedAt: now) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + + let model = SpendDashboardModel.build( + inputs: [ + .init( + provider: .codex, + displayName: "Codex", + snapshot: snapshot), + ], + requestedDays: 7, + now: now, + calendar: calendar) + + let group = try #require(model.groups.first) + let rows = group.models + #expect(rows.count == 2) + #expect(Set(rows.map(\.id)).count == 2) + #expect(Set(rows.compactMap(\.attribution)) == [inventoryAttribution, telemetryAttribution]) + } } From 0f4bda0d9a36b4579cfb2aef8055910d0b15a49b Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sun, 26 Jul 2026 20:52:56 -0700 Subject: [PATCH 005/183] Preserve CLIProxyAPI usage across scoped scans --- .../CodexBar/SpendDashboardController.swift | 89 +++++++++++++-- .../CLIProxyAPIUsageTelemetry.swift | 12 ++- Sources/CodexBarCore/CostUsageFetcher.swift | 84 ++++++++++++++- .../CLIProxyAPIAttributionResolverTests.swift | 101 ++++++++++++++++++ .../CodexBarTests/CostUsageFetcherTests.swift | 1 + .../SpendDashboardCodexProxySourceTests.swift | 81 ++++++++++++++ 6 files changed, 354 insertions(+), 14 deletions(-) create mode 100644 Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index 40642ff99e..10e85587b9 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -114,11 +114,21 @@ struct CodexSpendSnapshotLoadContext: Sendable { let includePiSessions: Bool } +struct CodexProxySpendSnapshotLoadContext: Sendable { + let now: Date + let force: Bool + let historyDays: Int + let refreshPricingInBackground: Bool +} + enum SpendDashboardSource { typealias CodexSnapshotLoader = @Sendable (CodexSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot + typealias CodexProxySnapshotLoader = @Sendable (CodexProxySpendSnapshotLoadContext) async throws + -> CostUsageTokenSnapshot static let scanDays = 30 + static let codexProxySourceID = "codex:cliproxyapi" @MainActor static func configuration(settings: SettingsStore, store: UsageStore) -> SpendDashboardConfiguration { @@ -140,11 +150,16 @@ enum SpendDashboardSource { providers: [UsageProvider], codexRequests: [CodexSpendScanRequest]) -> SpendDashboardConfiguration { - SpendDashboardConfiguration( + var codexDisplayNames = self.codexDisplayNamesByID(codexRequests) + if providers.contains(.codex) { + let providerName = store.metadata(for: .codex).displayName + codexDisplayNames[Self.codexProxySourceID] = "\(providerName) · CLIProxyAPI" + } + return SpendDashboardConfiguration( costUsageEnabled: settings.costUsageEnabled, providerIDs: providers.map(\.rawValue), codexAccountIdentities: codexRequests.map { "\($0.id)|\($0.cacheIdentity)" }, - codexAccountDisplayNames: self.codexDisplayNamesByID(codexRequests), + codexAccountDisplayNames: codexDisplayNames, sourceOwnershipFingerprints: self.sourceOwnershipFingerprints( providers: providers, settings: settings, @@ -246,14 +261,30 @@ enum SpendDashboardSource { } static func load(_ request: SpendDashboardLoadRequest) async -> SpendDashboardLoadResult { - await self.load(request, codexSnapshotLoader: { context in - try await self.loadCodexSnapshot(context) - }) + await self.load( + request, + codexSnapshotLoader: { context in + try await self.loadCodexSnapshot(context) + }, + codexProxySnapshotLoader: { context in + try await self.loadCodexProxySnapshot(context) + }) } static func load( _ request: SpendDashboardLoadRequest, codexSnapshotLoader: CodexSnapshotLoader) async -> SpendDashboardLoadResult + { + await self.load( + request, + codexSnapshotLoader: codexSnapshotLoader, + codexProxySnapshotLoader: nil) + } + + static func load( + _ request: SpendDashboardLoadRequest, + codexSnapshotLoader: CodexSnapshotLoader, + codexProxySnapshotLoader: CodexProxySnapshotLoader?) async -> SpendDashboardLoadResult { var inputs = request.capturedInputs var failedSourceIDs = request.unavailableSourceIDs @@ -299,6 +330,36 @@ enum SpendDashboardSource { failedSourceIDs.insert(sourceID) } } + if request.configuration.providerIDs.contains(UsageProvider.codex.rawValue), + let codexProxySnapshotLoader + { + do { + let snapshot = try await codexProxySnapshotLoader(CodexProxySpendSnapshotLoadContext( + now: request.now, + force: request.force, + historyDays: Self.scanDays, + refreshPricingInBackground: false)) + try Task.checkCancellation() + if !snapshot.daily.isEmpty { + let providerName = ProviderDescriptorRegistry.descriptor(for: .codex).metadata.displayName + inputs.append(SpendDashboardModel.ProviderInput( + id: Self.codexProxySourceID, + provider: .codex, + displayName: "\(providerName) · CLIProxyAPI", + modelProviderName: providerName, + snapshot: snapshot)) + } + } catch is CancellationError { + failedSourceIDs.formUnion(request.codexRequests.map { "codex:\($0.id)" }) + failedSourceIDs.insert(Self.codexProxySourceID) + return SpendDashboardLoadResult( + inputs: [], + failedSourceIDs: failedSourceIDs, + invalidatedSourceIDs: invalidatedSourceIDs) + } catch { + failedSourceIDs.insert(Self.codexProxySourceID) + } + } let lateInvalidatedSourceIDs = Set(request.codexRequests.compactMap { account in self.currentAuthFingerprint(for: account) == account.authFingerprint ? nil @@ -324,7 +385,18 @@ enum SpendDashboardSource { codexHomePath: context.account.homePath, historyDays: context.historyDays, refreshPricingInBackground: context.refreshPricingInBackground, - includePiSessions: context.includePiSessions) + includePiSessions: context.includePiSessions, + includeClaudeProxyUsage: false) + } + + private static func loadCodexProxySnapshot( + _ context: CodexProxySpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot + { + try await CostUsageFetcher().loadCodexProxyTokenSnapshot( + now: context.now, + forceRefresh: context.force, + historyDays: context.historyDays, + refreshPricingInBackground: context.refreshPricingInBackground) } @MainActor @@ -908,7 +980,10 @@ final class SpendDashboardController { let forceFailed = outcome.result.failedSourceIDs let invalidated = outcome.result.invalidatedSourceIDs let barrierFailed = capture.unavailableSourceIDs - let forcedCodexIDs = Set(outcome.request.codexRequests.map { "codex:\($0.id)" }) + var forcedCodexIDs = Set(outcome.request.codexRequests.map { "codex:\($0.id)" }) + if outcome.request.configuration.providerIDs.contains(UsageProvider.codex.rawValue) { + forcedCodexIDs.insert(SpendDashboardSource.codexProxySourceID) + } let confirmedNonemptyInputs = outcome.confirmedNonemptyInputs let confirmedNonemptyIDs = Set(confirmedNonemptyInputs.map(\.id)) var inputs = capture.capturedInputs.filter { diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index f090e26453..742ff8d471 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -142,7 +142,7 @@ enum CLIProxyAPIUsageCacheIO { static func merge( _ records: [CLIProxyAPIUsageRecord], cacheRoot: URL? = nil, - now: Date = Date()) -> Int + now: Date = Date()) -> Int? { guard !records.isEmpty else { return 0 } let cutoff = now.addingTimeInterval(-self.maximumRecordAge) @@ -155,7 +155,7 @@ enum CLIProxyAPIUsageCacheIO { byKey[self.recordKey(record)] = record } let cache = Cache(records: byKey.values.sorted { $0.timestamp < $1.timestamp }) - guard self.save(cache, cacheRoot: cacheRoot) else { return 0 } + guard self.save(cache, cacheRoot: cacheRoot) else { return nil } return max(0, byKey.count - priorCount) } @@ -341,15 +341,17 @@ public enum CLIProxyAPIUsageCollector { client: CLIProxyAPIUsageQueueClient) async -> CLIProxyAPIUsageCollectionResult { do { - var records: [CLIProxyAPIUsageRecord] = [] + var added = 0 for _ in 0.. CostUsageTokenSnapshot + includePiSessions: Bool = true, + includeClaudeProxyUsage: Bool = true) async throws -> CostUsageTokenSnapshot { try await Self.loadTokenSnapshot( provider: provider, @@ -91,6 +92,7 @@ public struct CostUsageFetcher: Sendable { allowPricingRefresh: allowPricingRefresh, refreshPricingInBackground: refreshPricingInBackground, includePiSessions: includePiSessions, + includeClaudeProxyUsage: includeClaudeProxyUsage, bypassScannerDebounce: false, scannerOptions: self.scannerOptionsOverride()) } @@ -107,6 +109,7 @@ public struct CostUsageFetcher: Sendable { allowPricingRefresh: Bool = true, refreshPricingInBackground: Bool = true, includePiSessions: Bool = true, + includeClaudeProxyUsage: Bool = true, bypassScannerDebounce: Bool) async throws -> CostUsageTokenSnapshot { try await Self.loadTokenSnapshot( @@ -121,10 +124,27 @@ public struct CostUsageFetcher: Sendable { allowPricingRefresh: allowPricingRefresh, refreshPricingInBackground: refreshPricingInBackground, includePiSessions: includePiSessions, + includeClaudeProxyUsage: includeClaudeProxyUsage, bypassScannerDebounce: bypassScannerDebounce, scannerOptions: self.scannerOptionsOverride()) } + package func loadCodexProxyTokenSnapshot( + now: Date = Date(), + forceRefresh: Bool = false, + historyDays: Int = 30, + allowPricingRefresh: Bool = true, + refreshPricingInBackground: Bool = true) async throws -> CostUsageTokenSnapshot + { + try await Self.loadCodexProxyTokenSnapshot(CodexProxyTokenSnapshotOptions( + now: now, + forceRefresh: forceRefresh, + historyDays: historyDays, + allowPricingRefresh: allowPricingRefresh, + refreshPricingInBackground: refreshPricingInBackground, + scannerOptions: self.scannerOptionsOverride())) + } + @available(*, deprecated, message: "Codex token-cost scans are uncapped; this limit is ignored.") public func loadTokenSnapshot( provider: UsageProvider, @@ -186,6 +206,7 @@ public struct CostUsageFetcher: Sendable { allowPricingRefresh: Bool = true, refreshPricingInBackground: Bool = true, includePiSessions: Bool = true, + includeClaudeProxyUsage: Bool = true, bypassScannerDebounce: Bool = false, scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil, piScannerOptions overridePiScannerOptions: PiSessionCostScanner @@ -289,7 +310,7 @@ public struct CostUsageFetcher: Sendable { options: scanOptions, range: range, now: now, - includeClaudeProxy: shouldMergePiUsage, + includeClaudeProxy: includeClaudeProxyUsage, checkCancellation: checkCancellation) projects = supplemental.projects sessions = supplemental.sessions @@ -344,6 +365,7 @@ public struct CostUsageFetcher: Sendable { allowPricingRefresh: allowPricingRefresh, refreshPricingInBackground: false, includePiSessions: includePiSessions, + includeClaudeProxyUsage: includeClaudeProxyUsage, scannerOptions: options, piScannerOptions: piOptions, modelsDevClient: modelsDevClient, @@ -717,12 +739,70 @@ public struct CostUsageFetcher: Sendable { } extension CostUsageFetcher { + private struct CodexProxyTokenSnapshotOptions { + let now: Date + let forceRefresh: Bool + let historyDays: Int + let allowPricingRefresh: Bool + let refreshPricingInBackground: Bool + let scannerOptions: CostUsageScanner.Options? + } + private struct CodexSupplementalScan { let projects: [CostUsageProjectBreakdown] let sessions: [CostUsageSessionBreakdown] let claudeProxyDaily: CostUsageDailyReport? } + private static func loadCodexProxyTokenSnapshot( + _ request: CodexProxyTokenSnapshotOptions) async throws -> CostUsageTokenSnapshot + { + let clampedHistoryDays = max(1, min(365, request.historyDays)) + let since = Calendar.current.date( + byAdding: .day, + value: -(clampedHistoryDays - 1), + to: request.now) ?? request.now + var options = Self.resolvedScannerOptions( + request.scannerOptions, + provider: .codex, + codexHomePath: nil) + await Self.refreshPricingIfAllowed( + options: PricingRefreshOptions( + provider: .codex, + isAllowed: request.allowPricingRefresh, + retryUnknown: true, + inBackground: request.refreshPricingInBackground), + now: request.now, + cacheRoot: options.cacheRoot, + client: ModelsDevClient()) + if request.forceRefresh { + options.refreshMinIntervalSeconds = 0 + } + + let scanOptions = options + let proxyDaily = try await CostUsageScanExecutor.run { checkCancellation in + let range = CostUsageScanner.CostUsageDayRange(since: since, until: request.now) + let supplemental = try Self.loadCodexSupplementalScan( + options: scanOptions, + range: range, + now: request.now, + includeClaudeProxy: true, + checkCancellation: checkCancellation) + return supplemental.claudeProxyDaily + } + let daily = proxyDaily ?? CostUsageDailyReport(data: [], summary: nil) + let projects = proxyDaily.flatMap { + Self.unknownProjectBreakdown( + from: $0, + name: "Claude Code via CLIProxyAPI") + }.map { [$0] } ?? [] + return Self.tokenSnapshot( + from: daily, + now: request.now, + historyDays: clampedHistoryDays, + projects: projects) + } + private static func loadCodexSupplementalScan( options: CostUsageScanner.Options, range: CostUsageScanner.CostUsageDayRange, diff --git a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift index f2bf94bd07..8290dac1ac 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift @@ -421,6 +421,76 @@ struct CLIProxyAPIAttributionResolverTests { ]) } + @Test + func `usage collector persists a full batch before a later pop fails`() async throws { + let fileManager = FileManager.default + let cacheRoot = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-partial-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: cacheRoot) } + let timestamp = try #require(CostUsageDateParser.parse("2026-07-16T12:00:00Z")) + let records = (0..<100).map { index in + CLIProxyAPIUsageRecord( + timestamp: timestamp.addingTimeInterval(TimeInterval(index)), + provider: "codex", + model: "gpt-5.6-sol", + alias: "gpt-5.6-sol", + endpoint: "POST /v1/messages", + authType: "oauth", + requestID: "request-\(index)", + tokens: .init(input: 10, output: 20, total: 30)) + } + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let sequence = try CLIProxyAPIBatchSequence(firstPayload: encoder.encode(records)) + let client = CLIProxyAPIUsageQueueClient( + settings: .init(managementKey: "management-secret"), + dataLoader: { request in + try await sequence.load(request) + }) + + let result = await CLIProxyAPIUsageCollector.collect(cacheRoot: cacheRoot, client: client) + + #expect(result == .failed("The second queue pop failed.")) + #expect(CLIProxyAPIUsageCacheIO.load(cacheRoot: cacheRoot).count == 100) + } + + @Test + func `usage collector reports cache write failure`() async throws { + let fileManager = FileManager.default + let cacheRoot = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-blocked-\(UUID().uuidString)", isDirectory: false) + try Data("not-a-directory".utf8).write(to: cacheRoot) + defer { try? fileManager.removeItem(at: cacheRoot) } + let timestamp = try #require(CostUsageDateParser.parse("2026-07-16T12:00:00Z")) + let record = CLIProxyAPIUsageRecord( + timestamp: timestamp, + provider: "codex", + model: "gpt-5.6-sol", + alias: "gpt-5.6-sol", + endpoint: "POST /v1/messages", + authType: "oauth", + requestID: "request-1", + tokens: .init(input: 10, output: 20, total: 30)) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode([record]) + let client = CLIProxyAPIUsageQueueClient( + settings: .init(managementKey: "management-secret"), + dataLoader: { request in + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + return (data, response) + }) + + let result = await CLIProxyAPIUsageCollector.collect(cacheRoot: cacheRoot, client: client) + + #expect(result == .failed("Could not save CLIProxyAPI usage telemetry.")) + } + @Test func `plain http management url is limited to loopback`() { #expect(CLIProxyAPIConnectionSettings( @@ -483,3 +553,34 @@ private actor CLIProxyAPICollectionConcurrencyProbe { self.maximumActiveCount } } + +private actor CLIProxyAPIBatchSequence { + private enum SequenceError: LocalizedError { + case secondPopFailed + + var errorDescription: String? { + "The second queue pop failed." + } + } + + private let firstPayload: Data + private var callCount = 0 + + init(firstPayload: Data) { + self.firstPayload = firstPayload + } + + func load(_ request: URLRequest) throws -> (Data, URLResponse) { + self.callCount += 1 + guard self.callCount == 1 else { + throw SequenceError.secondPopFailed + } + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + return (self.firstPayload, response) + } +} diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index 2b73d5fd80..56938be7c5 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -794,6 +794,7 @@ extension CostUsageFetcherTests { let codex = try await CostUsageFetcher.loadTokenSnapshot( provider: .codex, now: day, + codexHomePath: env.codexHomeRoot.path, allowPricingRefresh: false, includePiSessions: false, scannerOptions: options) diff --git a/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift b/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift new file mode 100644 index 0000000000..9564668519 --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift @@ -0,0 +1,81 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct SpendDashboardCodexProxySourceTests { + @Test + func `proxy usage loads once beside account scoped codex snapshots`() async { + let now = Date(timeIntervalSince1970: 1_784_179_200) + let accounts = ["first", "second"].map { id in + CodexSpendScanRequest( + id: id, + displayName: "Codex · \(id)", + source: .profileHome(path: "/synthetic/\(id)"), + homePath: "/synthetic/\(id)", + authFingerprint: nil, + authFileWasReadable: false, + cacheIdentity: "\(id)-cache") + } + let request = SpendDashboardLoadRequest( + configuration: SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: accounts.map { "\($0.id)|\($0.cacheIdentity)" }), + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: accounts, + now: now, + force: false) + let proxyRecorder = SpendDashboardCodexProxyLoadRecorder() + let accountSnapshot = Self.snapshot(cost: 1, now: now) + let proxySnapshot = Self.snapshot(cost: 2, now: now) + + let result = await SpendDashboardSource.load( + request, + codexSnapshotLoader: { _ in accountSnapshot }, + codexProxySnapshotLoader: { context in + await proxyRecorder.record(context) + return proxySnapshot + }) + let proxyContexts = await proxyRecorder.contexts + + #expect(Set(result.inputs.map(\.id)) == [ + "codex:first", + "codex:second", + SpendDashboardSource.codexProxySourceID, + ]) + #expect(result.inputs.count { $0.id == SpendDashboardSource.codexProxySourceID } == 1) + #expect(result.inputs.first { $0.id == SpendDashboardSource.codexProxySourceID }?.displayName == + "Codex · CLIProxyAPI") + #expect(proxyContexts.count == 1) + #expect(proxyContexts.first?.now == now) + } + + private static func snapshot(cost: Double, now: Date) -> CostUsageTokenSnapshot { + let entry = CostUsageDailyReport.Entry( + date: "2026-07-15", + inputTokens: nil, + outputTokens: nil, + totalTokens: 10, + costUSD: cost, + modelsUsed: ["gpt-5.6-sol"], + modelBreakdowns: nil) + return CostUsageTokenSnapshot( + sessionTokens: 10, + sessionCostUSD: cost, + last30DaysTokens: 10, + last30DaysCostUSD: cost, + daily: [entry], + updatedAt: now) + } +} + +private actor SpendDashboardCodexProxyLoadRecorder { + private(set) var contexts: [CodexProxySpendSnapshotLoadContext] = [] + + func record(_ context: CodexProxySpendSnapshotLoadContext) { + self.contexts.append(context) + } +} From e9b111ef6cde08d54c2cf00dc38f93a9bc433c4f Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sun, 26 Jul 2026 21:11:01 -0700 Subject: [PATCH 006/183] Prune stale CLIProxyAPI telemetry --- .../CLIProxyAPIUsageTelemetry.swift | 1 - .../CLIProxyAPIAttributionResolverTests.swift | 37 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 742ff8d471..994e83588e 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -144,7 +144,6 @@ enum CLIProxyAPIUsageCacheIO { cacheRoot: URL? = nil, now: Date = Date()) -> Int? { - guard !records.isEmpty else { return 0 } let cutoff = now.addingTimeInterval(-self.maximumRecordAge) var byKey: [String: CLIProxyAPIUsageRecord] = [:] for record in self.load(cacheRoot: cacheRoot) where record.timestamp >= cutoff { diff --git a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift index 8290dac1ac..e5f52df4b7 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift @@ -454,6 +454,43 @@ struct CLIProxyAPIAttributionResolverTests { #expect(CLIProxyAPIUsageCacheIO.load(cacheRoot: cacheRoot).count == 100) } + @Test + func `usage collector prunes expired cache records when the queue is empty`() async { + let fileManager = FileManager.default + let cacheRoot = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-prune-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: cacheRoot) } + let now = Date() + let expired = Self.record( + timestamp: now.addingTimeInterval(-367 * 24 * 60 * 60), + provider: "codex", + authType: "oauth") + let current = Self.record( + timestamp: now.addingTimeInterval(-24 * 60 * 60), + provider: "codex", + authType: "oauth") + #expect(CLIProxyAPIUsageCacheIO.merge( + [expired, current], + cacheRoot: cacheRoot, + now: expired.timestamp) == 2) + let client = CLIProxyAPIUsageQueueClient( + settings: .init(managementKey: "management-secret"), + dataLoader: { request in + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + return (Data("[]".utf8), response) + }) + + let result = await CLIProxyAPIUsageCollector.collect(cacheRoot: cacheRoot, client: client) + + #expect(result == .collected(0)) + #expect(CLIProxyAPIUsageCacheIO.load(cacheRoot: cacheRoot).map(\.requestID) == [current.requestID]) + } + @Test func `usage collector reports cache write failure`() async throws { let fileManager = FileManager.default From 02951c410602297f45a013c1acf42d9147eb3150 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sun, 26 Jul 2026 22:09:50 -0700 Subject: [PATCH 007/183] Preserve complete CLIProxyAPI attribution --- .../CodexBar/SpendDashboardController.swift | 25 +++++++-- .../CLIProxyAPIAttributionResolver.swift | 8 --- .../CLIProxyAPIAttributionResolverTests.swift | 53 +++++++++++++++++++ .../SpendDashboardCodexProxySourceTests.swift | 27 ++++++++++ 4 files changed, 100 insertions(+), 13 deletions(-) diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index 10e85587b9..2dcf8ca4f4 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -129,6 +129,13 @@ enum SpendDashboardSource { static let scanDays = 30 static let codexProxySourceID = "codex:cliproxyapi" + private static let isRunningTests: Bool = { + let environment = ProcessInfo.processInfo.environment + return environment["XCTestConfigurationFilePath"] != nil + || environment["TESTING_LIBRARY_VERSION"] != nil + || environment["SWIFT_TESTING"] != nil + || NSClassFromString("XCTestCase") != nil + }() @MainActor static func configuration(settings: SettingsStore, store: UsageStore) -> SpendDashboardConfiguration { @@ -261,14 +268,17 @@ enum SpendDashboardSource { } static func load(_ request: SpendDashboardLoadRequest) async -> SpendDashboardLoadResult { - await self.load( + let codexProxySnapshotLoader: CodexProxySnapshotLoader? = if self.isRunningTests { + nil + } else { + self.loadCodexProxySnapshot + } + return await self.load( request, codexSnapshotLoader: { context in try await self.loadCodexSnapshot(context) }, - codexProxySnapshotLoader: { context in - try await self.loadCodexProxySnapshot(context) - }) + codexProxySnapshotLoader: codexProxySnapshotLoader) } static func load( @@ -330,7 +340,7 @@ enum SpendDashboardSource { failedSourceIDs.insert(sourceID) } } - if request.configuration.providerIDs.contains(UsageProvider.codex.rawValue), + if self.shouldLoadCodexProxy(providerIDs: request.configuration.providerIDs), let codexProxySnapshotLoader { do { @@ -406,6 +416,11 @@ enum SpendDashboardSource { } } + static func shouldLoadCodexProxy(providerIDs: [String]) -> Bool { + providerIDs.contains(UsageProvider.codex.rawValue) + || providerIDs.contains(UsageProvider.claude.rawValue) + } + @MainActor static func codexRequests(settings: SettingsStore, store: UsageStore) -> [CodexSpendScanRequest] { let accounts = settings.codexVisibleAccountProjection.visibleAccounts diff --git a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift index b68286c75b..9605ac2cad 100644 --- a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift +++ b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift @@ -283,14 +283,6 @@ struct CLIProxyAPIAttributionResolver: Sendable { return urls .filter { $0.pathExtension.lowercased() == "log" } - .sorted { lhs, rhs in - let lhsValues = try? lhs.resourceValues(forKeys: [.contentModificationDateKey]) - let rhsValues = try? rhs.resourceValues(forKeys: [.contentModificationDateKey]) - let lhsDate = lhsValues?.contentModificationDate ?? .distantPast - let rhsDate = rhsValues?.contentModificationDate ?? .distantPast - return lhsDate > rhsDate - } - .prefix(500) .compactMap(self.parseObservation) } diff --git a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift index e5f52df4b7..ceeb05d232 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift @@ -276,6 +276,44 @@ struct CLIProxyAPIAttributionResolverTests { #expect(attribution.evidence.contains(.cliProxyUsageTelemetry)) } + @Test + func `filesystem loader preserves observations beyond five hundred newer logs`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-log-window-\(UUID().uuidString)", isDirectory: true) + let home = root.appendingPathComponent("home", isDirectory: true) + let logs = home.appendingPathComponent("logs", isDirectory: true) + try fileManager.createDirectory(at: logs, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + let timestamp = try #require(CostUsageDateParser.parse("2026-01-01T12:00:00Z")) + let targetURL = logs.appendingPathComponent("target.log") + try Data(Self.requestLog( + sessionID: "target-session", + timestamp: timestamp).utf8).write(to: targetURL) + try fileManager.setAttributes([.modificationDate: timestamp], ofItemAtPath: targetURL.path) + for index in 0..<500 { + let newerTimestamp = timestamp.addingTimeInterval(TimeInterval(index + 1)) + let url = logs.appendingPathComponent("newer-\(index).log") + try Data(Self.requestLog( + sessionID: "newer-session-\(index)", + timestamp: newerTimestamp).utf8).write(to: url) + try fileManager.setAttributes( + [.modificationDate: newerTimestamp], + ofItemAtPath: url.path) + } + + let resolver = CLIProxyAPIAttributionResolver.load(home: home, fileManager: fileManager) + let attribution = resolver.attribution( + model: "gpt-5.6-sol", + modelProvider: .openAI, + sessionID: "target-session", + timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), + tokens: Self.tokens) + + #expect(attribution.route == .cliProxyAPI) + #expect(attribution.evidence.contains(.cliProxyRequestLog)) + } + @Test func `usage cache never persists source or api key fields`() throws { let fileManager = FileManager.default @@ -550,6 +588,21 @@ struct CLIProxyAPIAttributionResolverTests { cacheCreate: 40, output: 20) + private static func requestLog(sessionID: String, timestamp: Date) -> String { + """ + === REQUEST INFO === + URL: /v1/messages + Method: POST + Timestamp: \(ISO8601DateFormatter().string(from: timestamp)) + === HEADERS === + X-Claude-Code-Session-Id: \(sessionID) + === REQUEST BODY === + {"model":"gpt-5.6-sol"} + === RESPONSE === + Status: 200 + """ + } + private static func record( timestamp: Date, provider: String, diff --git a/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift b/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift index 9564668519..54d4067789 100644 --- a/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift +++ b/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift @@ -53,6 +53,33 @@ struct SpendDashboardCodexProxySourceTests { #expect(proxyContexts.first?.now == now) } + @Test + func `proxy usage loads when claude is enabled without codex`() async { + let now = Date(timeIntervalSince1970: 1_784_179_200) + let request = SpendDashboardLoadRequest( + configuration: SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.claude.rawValue], + codexAccountIdentities: []), + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + now: now, + force: false) + let proxySnapshot = Self.snapshot(cost: 2, now: now) + let emptySnapshot = Self.snapshot(cost: 0, now: now) + + let result = await SpendDashboardSource.load( + request, + codexSnapshotLoader: { _ in + Issue.record("No account-scoped Codex snapshot should be requested.") + return emptySnapshot + }, + codexProxySnapshotLoader: { _ in proxySnapshot }) + + #expect(result.inputs.map(\.id) == [SpendDashboardSource.codexProxySourceID]) + } + private static func snapshot(cost: Double, now: Date) -> CostUsageTokenSnapshot { let entry = CostUsageDailyReport.Entry( date: "2026-07-15", From b9192ab8c4fcfbbeb6651896086ba761eb24ed7f Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sun, 26 Jul 2026 22:36:15 -0700 Subject: [PATCH 008/183] Retain proxy spend after forced refresh --- .../CodexBar/SpendDashboardController.swift | 4 ++- ...SpendDashboardForceStateMachineTests.swift | 36 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index 2dcf8ca4f4..a313ce2f7e 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -996,7 +996,9 @@ final class SpendDashboardController { let invalidated = outcome.result.invalidatedSourceIDs let barrierFailed = capture.unavailableSourceIDs var forcedCodexIDs = Set(outcome.request.codexRequests.map { "codex:\($0.id)" }) - if outcome.request.configuration.providerIDs.contains(UsageProvider.codex.rawValue) { + if SpendDashboardSource.shouldLoadCodexProxy( + providerIDs: outcome.request.configuration.providerIDs) + { forcedCodexIDs.insert(SpendDashboardSource.codexProxySourceID) } let confirmedNonemptyInputs = outcome.confirmedNonemptyInputs diff --git a/Tests/CodexBarTests/SpendDashboardForceStateMachineTests.swift b/Tests/CodexBarTests/SpendDashboardForceStateMachineTests.swift index e0c1f152c6..d9b8a5e3db 100644 --- a/Tests/CodexBarTests/SpendDashboardForceStateMachineTests.swift +++ b/Tests/CodexBarTests/SpendDashboardForceStateMachineTests.swift @@ -92,6 +92,42 @@ struct SpendDashboardForceStateMachineTests { #expect(controller.model.groups.first?.totalCost == 12) } + @Test + func `forced proxy success carries through a Claude only capture barrier`() async { + let configuration = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.claude.rawValue], + codexAccountIdentities: []) + let builder = SpendDashboardBuildScript([ + .init( + mode: .forceRefresh, + request: Self.request(configuration, mode: .forceRefresh)), + .init( + mode: .captureOnly, + request: Self.request(configuration, mode: .captureOnly)), + ]) + let loader = SpendDashboardStateLoaderGate() + let controller = SpendDashboardController( + requestBuilder: { mode in await builder.next(mode) }, + loader: { request in await loader.load(request) }) + + controller.update(configuration: configuration, force: true) + await Self.waitForLoader(loader) + await loader.resume(SpendDashboardLoadResult( + inputs: [Self.input( + id: SpendDashboardSource.codexProxySourceID, + provider: .codex, + cost: 5)], + failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + #expect(builder.modes == [.forceRefresh, .captureOnly]) + #expect(await loader.forces == [true]) + #expect(controller.model.groups.first?.totalCost == 5) + #expect(controller.model.groups.flatMap(\.providers).map(\.id) == + [SpendDashboardSource.codexProxySourceID]) + } + @Test func `C same owner barrier churn repeats capture only and preserves failures`() async { let initial = Self.configuration(owner: "owner", revision: "R") From 19f7f1b6e37519b84ac000370e3cb2b1da52afb0 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sun, 26 Jul 2026 23:24:27 -0700 Subject: [PATCH 009/183] Harden CLIProxyAPI telemetry maintenance --- .../CLIProxyAPIAttributionResolver.swift | 22 +++-- .../CLIProxyAPIUsageTelemetry.swift | 28 ++++-- Sources/CodexBarCore/CostUsageFetcher.swift | 18 ++-- .../CostUsage/CostUsageScanner+Claude.swift | 11 ++- .../CLIProxyAPIAttributionResolverTests.swift | 86 ++++++++++++++++++- 5 files changed, 138 insertions(+), 27 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift index 9605ac2cad..30563c40c6 100644 --- a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift +++ b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift @@ -45,11 +45,13 @@ struct CLIProxyAPIAttributionResolver: Sendable { static func load( home: URL, cacheRoot: URL? = nil, - fileManager: FileManager = .default) -> Self + fileManager: FileManager = .default, + checkCancellation: (() throws -> Void)? = nil) throws -> Self { - let observations = self.loadObservations( + let observations = try self.loadObservations( logDirectory: home.appendingPathComponent("logs", isDirectory: true), - fileManager: fileManager) + fileManager: fileManager, + checkCancellation: checkCancellation) return Self( observations: observations, usageRecords: CLIProxyAPIUsageCacheIO.load(cacheRoot: cacheRoot), @@ -273,7 +275,8 @@ struct CLIProxyAPIAttributionResolver: Sendable { private static func loadObservations( logDirectory: URL, - fileManager: FileManager) -> [Observation] + fileManager: FileManager, + checkCancellation: (() throws -> Void)?) throws -> [Observation] { guard let urls = try? fileManager.contentsOfDirectory( at: logDirectory, @@ -281,9 +284,14 @@ struct CLIProxyAPIAttributionResolver: Sendable { options: [.skipsHiddenFiles]) else { return [] } - return urls - .filter { $0.pathExtension.lowercased() == "log" } - .compactMap(self.parseObservation) + var observations: [Observation] = [] + for url in urls where url.pathExtension.lowercased() == "log" { + try checkCancellation?() + if let observation = self.parseObservation(url: url) { + observations.append(observation) + } + } + return observations } private static func parseObservation(url: URL) -> Observation? { diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 994e83588e..16d1d430a5 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -123,19 +123,19 @@ struct CLIProxyAPIUsageRecord: Codable, Equatable, Sendable { } enum CLIProxyAPIUsageCacheIO { - private struct Cache: Codable { + private struct Cache: Codable, Equatable { var version: Int = 1 var records: [CLIProxyAPIUsageRecord] = [] } private static let maximumRecordAge: TimeInterval = 366 * 24 * 60 * 60 - static func load(cacheRoot: URL? = nil) -> [CLIProxyAPIUsageRecord] { - guard let data = try? Data(contentsOf: self.cacheFileURL(cacheRoot: cacheRoot)), - let cache = try? self.decoder.decode(Cache.self, from: data), - cache.version == 1 - else { return [] } - return cache.records + static func load( + cacheRoot: URL? = nil, + now: Date = Date()) -> [CLIProxyAPIUsageRecord] + { + let cutoff = now.addingTimeInterval(-self.maximumRecordAge) + return self.loadCache(cacheRoot: cacheRoot).records.filter { $0.timestamp >= cutoff } } @discardableResult @@ -145,8 +145,9 @@ enum CLIProxyAPIUsageCacheIO { now: Date = Date()) -> Int? { let cutoff = now.addingTimeInterval(-self.maximumRecordAge) + let existingCache = self.loadCache(cacheRoot: cacheRoot) var byKey: [String: CLIProxyAPIUsageRecord] = [:] - for record in self.load(cacheRoot: cacheRoot) where record.timestamp >= cutoff { + for record in existingCache.records where record.timestamp >= cutoff { byKey[self.recordKey(record)] = record } let priorCount = byKey.count @@ -154,6 +155,9 @@ enum CLIProxyAPIUsageCacheIO { byKey[self.recordKey(record)] = record } let cache = Cache(records: byKey.values.sorted { $0.timestamp < $1.timestamp }) + if cache == existingCache { + return 0 + } guard self.save(cache, cacheRoot: cacheRoot) else { return nil } return max(0, byKey.count - priorCount) } @@ -209,6 +213,14 @@ enum CLIProxyAPIUsageCacheIO { ].joined(separator: ":") } + private static func loadCache(cacheRoot: URL?) -> Cache { + guard let data = try? Data(contentsOf: self.cacheFileURL(cacheRoot: cacheRoot)), + let cache = try? self.decoder.decode(Cache.self, from: data), + cache.version == 1 + else { return Cache() } + return cache + } + private static func save(_ cache: Cache, cacheRoot: URL?) -> Bool { let url = self.cacheFileURL(cacheRoot: cacheRoot) let directory = url.deletingLastPathComponent() diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 4928b3bd81..7be312429b 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -493,9 +493,11 @@ public struct CostUsageFetcher: Sendable { return nil } + typealias CachedResult = CachedCodexTokenSnapshotResult? + // Decoding the persisted scan cache parses multi-megabyte JSON; keep it off the // cooperative pool alongside the scans themselves. - let cachedSnapshot: CachedCodexTokenSnapshotResult?? = try? await CostUsageScanExecutor.run { _ in + let cachedSnapshot: CachedResult? = try? await CostUsageScanExecutor.run { checkCancellation in let clampedHistoryDays = max(1, min(365, historyDays)) let until = now let since = Calendar.current.date(byAdding: .day, value: -(clampedHistoryDays - 1), to: now) ?? now @@ -548,15 +550,19 @@ public struct CostUsageFetcher: Sendable { if !claudeCache.days.isEmpty, !CostUsageScanner.requestedWindowExpandsCache(range: range, cache: claudeCache) { + let attributionResolver: CLIProxyAPIAttributionResolver? = if let home = options.cliProxyAPIHome { + try CLIProxyAPIAttributionResolver.load( + home: home, + cacheRoot: options.cacheRoot, + checkCancellation: checkCancellation) + } else { + nil + } let proxyDaily = CostUsageScanner.buildClaudeReportFromCache( cache: claudeCache, range: range, attributionFilter: .codexBackendOnly, - attributionResolver: options.cliProxyAPIHome.map { - CLIProxyAPIAttributionResolver.load( - home: $0, - cacheRoot: options.cacheRoot) - }, + attributionResolver: attributionResolver, modelsDevCatalog: CostUsagePricing.modelsDevCatalog( now: now, cacheRoot: options.cacheRoot), diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 4ec118c34c..6419fed868 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -773,10 +773,13 @@ extension CostUsageScanner { || nowMs - cache.lastScanUnixMs > refreshMs let providerFilter = options.claudeLogProviderFilter - let attributionResolver = options.cliProxyAPIHome.map { - CLIProxyAPIAttributionResolver.load( - home: $0, - cacheRoot: options.cacheRoot) + let attributionResolver: CLIProxyAPIAttributionResolver? = if let home = options.cliProxyAPIHome { + try CLIProxyAPIAttributionResolver.load( + home: home, + cacheRoot: options.cacheRoot, + checkCancellation: checkCancellation) + } else { + nil } var touched: Set = [] diff --git a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift index ceeb05d232..43489005b0 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift @@ -260,7 +260,7 @@ struct CLIProxyAPIAttributionResolverTests { cacheRoot: cacheRoot, now: timestamp) - let resolver = CLIProxyAPIAttributionResolver.load( + let resolver = try CLIProxyAPIAttributionResolver.load( home: home, cacheRoot: cacheRoot, fileManager: fileManager) @@ -302,7 +302,7 @@ struct CLIProxyAPIAttributionResolverTests { ofItemAtPath: url.path) } - let resolver = CLIProxyAPIAttributionResolver.load(home: home, fileManager: fileManager) + let resolver = try CLIProxyAPIAttributionResolver.load(home: home, fileManager: fileManager) let attribution = resolver.attribution( model: "gpt-5.6-sol", modelProvider: .openAI, @@ -314,6 +314,27 @@ struct CLIProxyAPIAttributionResolverTests { #expect(attribution.evidence.contains(.cliProxyRequestLog)) } + @Test + func `filesystem loader checks cancellation before reading request logs`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-log-cancellation-\(UUID().uuidString)", isDirectory: true) + let home = root.appendingPathComponent("home", isDirectory: true) + let logs = home.appendingPathComponent("logs", isDirectory: true) + try fileManager.createDirectory(at: logs, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + try Data(Self.requestLog( + sessionID: "cancelled-session", + timestamp: Date()).utf8).write(to: logs.appendingPathComponent("request.log")) + + #expect(throws: CancellationError.self) { + try CLIProxyAPIAttributionResolver.load( + home: home, + fileManager: fileManager, + checkCancellation: { throw CancellationError() }) + } + } + @Test func `usage cache never persists source or api key fields`() throws { let fileManager = FileManager.default @@ -529,6 +550,67 @@ struct CLIProxyAPIAttributionResolverTests { #expect(CLIProxyAPIUsageCacheIO.load(cacheRoot: cacheRoot).map(\.requestID) == [current.requestID]) } + @Test + func `usage cache filters expired records during load`() { + let fileManager = FileManager.default + let cacheRoot = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-load-retention-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: cacheRoot) } + let now = Date() + let expired = Self.record( + timestamp: now.addingTimeInterval(-367 * 24 * 60 * 60), + provider: "codex", + authType: "oauth") + let current = Self.record( + timestamp: now.addingTimeInterval(-24 * 60 * 60), + provider: "codex", + authType: "oauth") + #expect(CLIProxyAPIUsageCacheIO.merge( + [expired, current], + cacheRoot: cacheRoot, + now: expired.timestamp) == 2) + + #expect(CLIProxyAPIUsageCacheIO.load( + cacheRoot: cacheRoot, + now: now).map(\.requestID) == [current.requestID]) + } + + @Test + func `empty usage poll does not rewrite an unchanged cache`() async throws { + let fileManager = FileManager.default + let cacheRoot = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-empty-poll-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: cacheRoot) } + let current = Self.record( + timestamp: Date(), + provider: "codex", + authType: "oauth") + #expect(CLIProxyAPIUsageCacheIO.merge([current], cacheRoot: cacheRoot) == 1) + let cacheURL = CLIProxyAPIUsageCacheIO.cacheFileURL(cacheRoot: cacheRoot) + let marker = Date(timeIntervalSince1970: 1_700_000_000) + try fileManager.setAttributes([.modificationDate: marker], ofItemAtPath: cacheURL.path) + let before = try #require( + fileManager.attributesOfItem(atPath: cacheURL.path)[.modificationDate] as? Date) + let client = CLIProxyAPIUsageQueueClient( + settings: .init(managementKey: "management-secret"), + dataLoader: { request in + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + return (Data("[]".utf8), response) + }) + + let result = await CLIProxyAPIUsageCollector.collect(cacheRoot: cacheRoot, client: client) + let after = try #require( + fileManager.attributesOfItem(atPath: cacheURL.path)[.modificationDate] as? Date) + + #expect(result == .collected(0)) + #expect(after == before) + } + @Test func `usage collector reports cache write failure`() async throws { let fileManager = FileManager.default From b02b484ffa7563b81f8bbd442dff921286d4e00b Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 27 Jul 2026 00:24:48 -0700 Subject: [PATCH 010/183] Fix proxied model pricing boundaries --- .../CostUsage/CostUsageScanner+Claude.swift | 43 ++++++++++++++----- .../CodexBarTests/CostUsageFetcherTests.swift | 15 +++---- 2 files changed, 38 insertions(+), 20 deletions(-) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 6419fed868..950b379e32 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -75,18 +75,26 @@ extension CostUsageScanner { let attribution = resolvedAttribution.route == .cliProxyAPI || modelProvider != .anthropic ? resolvedAttribution : nil - let cost: Double? = if modelProvider == .openAI { + let upstreamModel = resolvedAttribution.route == .cliProxyAPI + ? resolvedAttribution.upstream?.model?.trimmingCharacters(in: .whitespacesAndNewlines) + : nil + let pricingModel = upstreamModel.flatMap { $0.isEmpty ? nil : $0 } ?? model + let pricingProvider = CostUsagePricing.modelProvider( + for: pricingModel, + modelsDevCatalog: context.modelsDevCatalog, + modelsDevCacheRoot: context.modelsDevCacheRoot) + let cost: Double? = if pricingProvider == .openAI { CostUsagePricing.claudeProxyCodexCostUSD( - model: model, + model: pricingModel, inputTokens: tokens.input, cacheReadInputTokens: tokens.cacheRead, cacheCreationInputTokens: tokens.cacheCreate, outputTokens: tokens.output, modelsDevCatalog: context.modelsDevCatalog, modelsDevCacheRoot: context.modelsDevCacheRoot) - } else if modelProvider == .anthropic { + } else if pricingProvider == .anthropic { CostUsagePricing.claudeCostUSD( - model: model, + model: pricingModel, inputTokens: tokens.input, cacheReadInputTokens: tokens.cacheRead, cacheCreationInputTokens: tokens.cacheCreate, @@ -882,10 +890,13 @@ extension CostUsageScanner { } let isCodexBackend = attribution?.route == .cliProxyAPI && attribution?.upstream?.isCodex == true + let isUnresolvedForeignModel = attribution?.route != .cliProxyAPI + && modelProvider != .anthropic + && modelProvider != .unknown let includeRow = switch attributionFilter { case .all: true case .codexBackendOnly: isCodexBackend - case .excludeCodexBackend: !isCodexBackend + case .excludeCodexBackend: !isCodexBackend && !isUnresolvedForeignModel } guard includeRow else { continue } @@ -906,9 +917,18 @@ extension CostUsageScanner { var cost = result.repricedCosts[key] ?? ClaudeRepricedCost() cost.sampleCount += 1 let wasPriced = row.costPriced ?? (row.costNanos > 0) + let upstreamModel = attribution?.route == .cliProxyAPI + ? attribution?.upstream?.model?.trimmingCharacters(in: .whitespacesAndNewlines) + : nil + let pricingModel = upstreamModel.flatMap { $0.isEmpty ? nil : $0 } ?? row.model + let pricingProvider = CostUsagePricing.modelProvider( + for: pricingModel, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) let currentCost = Self.currentClaudeRowCost( row, - modelProvider: modelProvider, + pricingModel: pricingModel, + pricingProvider: pricingProvider, modelsDevCatalog: modelsDevCatalog, modelsDevCacheRoot: modelsDevCacheRoot) let resolvedCost: Double? = if wasPriced, row.costNanos == 0 { @@ -932,13 +952,14 @@ extension CostUsageScanner { private static func currentClaudeRowCost( _ row: ClaudeUsageRow, - modelProvider: CostUsageAttribution.ModelProvider, + pricingModel: String, + pricingProvider: CostUsageAttribution.ModelProvider, modelsDevCatalog: ModelsDevCatalog?, modelsDevCacheRoot: URL?) -> Double? { - if modelProvider == .openAI { + if pricingProvider == .openAI { return CostUsagePricing.claudeProxyCodexCostUSD( - model: row.model, + model: pricingModel, inputTokens: row.input, cacheReadInputTokens: row.cacheRead, cacheCreationInputTokens: row.cacheCreate, @@ -946,9 +967,9 @@ extension CostUsageScanner { modelsDevCatalog: modelsDevCatalog, modelsDevCacheRoot: modelsDevCacheRoot) } - guard modelProvider == .anthropic else { return nil } + guard pricingProvider == .anthropic else { return nil } return CostUsagePricing.claudeCostUSD( - model: row.model, + model: pricingModel, inputTokens: row.input, cacheReadInputTokens: row.cacheRead, cacheCreationInputTokens: row.cacheCreate, diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index 56938be7c5..76b328f96d 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -722,7 +722,7 @@ extension CostUsageFetcherTests { "requestId": "request-proxy", "message": [ "id": "message-proxy", - "model": "gpt-5.6-sol", + "model": "claude-sonnet-4-6", "usage": [ "input_tokens": 100, "cache_creation_input_tokens": 10, @@ -761,7 +761,7 @@ extension CostUsageFetcherTests { === HEADERS === X-Claude-Code-Session-Id: session-proxy === REQUEST BODY === - {"model":"gpt-5.6-sol"} + {"model":"claude-sonnet-4-6"} === API RESPONSE === """ try Data(proxyLog.utf8).write(to: cliProxyLogs.appendingPathComponent("request.log")) @@ -772,7 +772,7 @@ extension CostUsageFetcherTests { provider: "codex", executorType: "CodexExecutor", model: "gpt-5.6-sol", - alias: "gpt-5.6-sol", + alias: "claude-sonnet-4-6", endpoint: "/v1/messages", authType: "oauth", requestID: "cliproxy-request-proxy", @@ -817,11 +817,11 @@ extension CostUsageFetcherTests { let codexBreakdown = try #require(codex.daily.first?.modelBreakdowns?.first) #expect(codex.daily.first?.totalTokens == 135) #expect(abs((codex.daily.first?.costUSD ?? 0) - expectedCodexCost) < 0.000001) - #expect(codexBreakdown.modelName == "gpt-5.6-sol") + #expect(codexBreakdown.modelName == "claude-sonnet-4-6") #expect(codexBreakdown.attribution == CostUsageAttribution( client: .claudeCode, route: .cliProxyAPI, - modelProvider: .openAI, + modelProvider: .anthropic, upstream: .init( provider: "codex", authType: .oauth, @@ -965,10 +965,7 @@ extension CostUsageFetcherTests { scannerOptions: options) #expect(codex.daily.isEmpty) - let attribution = try #require(claude.daily.first?.modelBreakdowns?.first?.attribution) - #expect(attribution.modelProvider == .openAI) - #expect(attribution.route == .unknown) - #expect(attribution.upstream == nil) + #expect(claude.daily.isEmpty) } @Test From 3ce2fcc137e8df553c01c5180f5a015eaca10b1d Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 27 Jul 2026 01:00:41 -0700 Subject: [PATCH 011/183] Retry proxy-only unknown pricing --- Sources/CodexBarCore/CostUsageFetcher.swift | 49 +++++++++- ...UsageFetcherUnknownModelPricingTests.swift | 91 +++++++++++++++++++ 2 files changed, 137 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 7be312429b..dd69ead854 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -135,6 +135,23 @@ public struct CostUsageFetcher: Sendable { historyDays: Int = 30, allowPricingRefresh: Bool = true, refreshPricingInBackground: Bool = true) async throws -> CostUsageTokenSnapshot + { + try await self.loadCodexProxyTokenSnapshot( + now: now, + forceRefresh: forceRefresh, + historyDays: historyDays, + allowPricingRefresh: allowPricingRefresh, + refreshPricingInBackground: refreshPricingInBackground, + modelsDevClient: ModelsDevClient()) + } + + func loadCodexProxyTokenSnapshot( + now: Date, + forceRefresh: Bool, + historyDays: Int = 30, + allowPricingRefresh: Bool = true, + refreshPricingInBackground: Bool, + modelsDevClient: ModelsDevClient) async throws -> CostUsageTokenSnapshot { try await Self.loadCodexProxyTokenSnapshot(CodexProxyTokenSnapshotOptions( now: now, @@ -142,7 +159,9 @@ public struct CostUsageFetcher: Sendable { historyDays: historyDays, allowPricingRefresh: allowPricingRefresh, refreshPricingInBackground: refreshPricingInBackground, - scannerOptions: self.scannerOptionsOverride())) + scannerOptions: self.scannerOptionsOverride(), + modelsDevClient: modelsDevClient, + retryUnknownPricing: true)) } @available(*, deprecated, message: "Codex token-cost scans are uncapped; this limit is ignored.") @@ -752,6 +771,8 @@ extension CostUsageFetcher { let allowPricingRefresh: Bool let refreshPricingInBackground: Bool let scannerOptions: CostUsageScanner.Options? + let modelsDevClient: ModelsDevClient + let retryUnknownPricing: Bool } private struct CodexSupplementalScan { @@ -776,11 +797,11 @@ extension CostUsageFetcher { options: PricingRefreshOptions( provider: .codex, isAllowed: request.allowPricingRefresh, - retryUnknown: true, + retryUnknown: request.retryUnknownPricing, inBackground: request.refreshPricingInBackground), now: request.now, cacheRoot: options.cacheRoot, - client: ModelsDevClient()) + client: request.modelsDevClient) if request.forceRefresh { options.refreshMinIntervalSeconds = 0 } @@ -802,6 +823,28 @@ extension CostUsageFetcher { from: $0, name: "Claude Code via CLIProxyAPI") }.map { [$0] } ?? [] + if request.allowPricingRefresh, + request.retryUnknownPricing, + let refreshRequest = Self.unknownPricingRefreshRequest( + provider: .codex, + daily: daily, + now: request.now, + cacheRoot: options.cacheRoot, + client: request.modelsDevClient), + await Self.refreshUnknownPricingIfNeeded( + refreshRequest, + inBackground: request.refreshPricingInBackground) + { + return try await Self.loadCodexProxyTokenSnapshot(CodexProxyTokenSnapshotOptions( + now: request.now, + forceRefresh: request.forceRefresh, + historyDays: request.historyDays, + allowPricingRefresh: request.allowPricingRefresh, + refreshPricingInBackground: false, + scannerOptions: options, + modelsDevClient: request.modelsDevClient, + retryUnknownPricing: false)) + } return Self.tokenSnapshot( from: daily, now: request.now, diff --git a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift index 4ea858f6c4..dbc1f9f69f 100644 --- a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift @@ -187,6 +187,97 @@ struct CostUsageFetcherUnknownModelPricingTests { #expect(breakdown.costUSD == nil) #expect(await counter.requestCount == 0) } + + @Test + func `proxy-only fetcher reprices an unknown upstream model after catalog refresh`() async throws { + let environment = try CostUsageTestEnvironment() + defer { environment.cleanup() } + let day = try environment.makeLocalNoon(year: 2026, month: 7, day: 24) + let freshCatalog = try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(""" + { + "openai": { + "id": "openai", + "models": { "gpt-old": { "id": "gpt-old", "cost": { "input": 1, "output": 4 } } } + } + } + """.utf8)) + ModelsDevCache.save( + catalog: freshCatalog, + fetchedAt: day.addingTimeInterval(-901), + cacheRoot: environment.cacheRoot) + + _ = try environment.writeClaudeProjectFile( + relativePath: "proxy/unknown-model.jsonl", + contents: environment.jsonl([[ + "type": "assistant", + "timestamp": environment.isoString(for: day), + "sessionId": "session-proxy", + "requestId": "request-proxy", + "message": [ + "id": "message-proxy", + "model": "gpt-new", + "usage": ["input_tokens": 100, "output_tokens": 10], + ], + ]])) + let cliProxyHome = environment.root.appendingPathComponent("cli-proxy-api", isDirectory: true) + let cliProxyLogs = cliProxyHome.appendingPathComponent("logs", isDirectory: true) + try FileManager.default.createDirectory(at: cliProxyLogs, withIntermediateDirectories: true) + try Data(#"{"type":"codex"}"#.utf8) + .write(to: cliProxyHome.appendingPathComponent("codex-auth.json")) + let proxyLog = """ + === REQUEST INFO === + URL: /v1/messages + Timestamp: \(environment.isoString(for: day)) + === HEADERS === + X-Claude-Code-Session-Id: session-proxy + === REQUEST BODY === + {"model":"gpt-new"} + === API RESPONSE === + """ + try Data(proxyLog.utf8).write(to: cliProxyLogs.appendingPathComponent("request.log")) + CLIProxyAPIUsageCacheIO.merge( + [ + CLIProxyAPIUsageRecord( + timestamp: day, + provider: "codex", + executorType: "CodexExecutor", + model: "gpt-new", + alias: "gpt-new", + endpoint: "/v1/messages", + authType: "oauth", + requestID: "cliproxy-request", + tokens: .init(input: 100, output: 10, total: 110)), + ], + cacheRoot: environment.cacheRoot, + now: day) + let options = CostUsageScanner.Options( + claudeProjectsRoots: [environment.claudeProjectsRoot], + cacheRoot: environment.cacheRoot, + cliProxyAPIHome: cliProxyHome) + let refreshedCatalog = Data(""" + { + "openai": { + "id": "openai", + "models": { "gpt-new": { "id": "gpt-new", "cost": { "input": 2, "output": 8 } } } + }, + "anthropic": { + "id": "anthropic", + "models": { "claude-new": { "id": "claude-new", "cost": { "input": 3, "output": 15 } } } + } + } + """.utf8) + + let snapshot = try await CostUsageFetcher(scannerOptions: options).loadCodexProxyTokenSnapshot( + now: day, + forceRefresh: true, + refreshPricingInBackground: false, + modelsDevClient: ModelsDevClient(transport: CostUsageFetcherModelsDevTransport( + data: refreshedCatalog))) + + let breakdown = try #require(snapshot.daily.first?.modelBreakdowns?.first) + #expect(breakdown.modelName == "gpt-new") + #expect(abs((breakdown.costUSD ?? 0) - 0.00028) < 0.0000001) + } } private struct UnknownModelPricingFixture { From eb1f283b3205af3fa2b7d8ddb3963052769a93a6 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 27 Jul 2026 01:53:13 -0700 Subject: [PATCH 012/183] Isolate and index proxy attribution --- .../CLIProxyAPIAttributionResolver.swift | 60 +++++++++++++++---- Sources/CodexBarCore/CostUsageFetcher.swift | 6 +- .../CLIProxyAPIAttributionResolverTests.swift | 36 +++++++++++ .../CodexBarTests/CostUsageFetcherTests.swift | 14 ++++- 4 files changed, 99 insertions(+), 17 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift index 30563c40c6..95885f2bdc 100644 --- a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift +++ b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift @@ -26,7 +26,7 @@ struct CLIProxyAPIAttributionResolver: Sendable { private static let maximumTelemetryMatchDistance: TimeInterval = 5 private let observationsBySessionID: [String: [Observation]] - private let usageRecords: [CLIProxyAPIUsageRecord] + private let usageRecordsByCanonicalModel: [String: [CLIProxyAPIUsageRecord]] private let authProviders: [AuthProvider] private let hasConfiguredOpenAIAPIUpstream: Bool @@ -37,7 +37,7 @@ struct CLIProxyAPIAttributionResolver: Sendable { hasConfiguredOpenAIAPIUpstream: Bool = false) { self.observationsBySessionID = Dictionary(grouping: observations, by: \.sessionID) - self.usageRecords = usageRecords + self.usageRecordsByCanonicalModel = Self.indexUsageRecords(usageRecords) self.authProviders = authProviders self.hasConfiguredOpenAIAPIUpstream = hasConfiguredOpenAIAPIUpstream } @@ -143,21 +143,55 @@ struct CLIProxyAPIAttributionResolver: Sendable { { guard let observationTimestamp = observation.timestamp else { return nil } let canonicalModel = Self.canonicalModel(model) - let candidates = self.usageRecords.filter { record in - guard !record.failed, - record.generate, - record.endpoint.lowercased().contains("/v1/messages"), - abs(record.timestamp.timeIntervalSince(observationTimestamp)) - <= Self.maximumTelemetryMatchDistance - else { return false } - let modelMatches = Self.canonicalModel(record.alias) == canonicalModel - || Self.canonicalModel(record.model) == canonicalModel - guard modelMatches else { return false } - return tokens.map { Self.tokensMatch($0, record.tokens) } ?? true + guard let records = self.usageRecordsByCanonicalModel[canonicalModel] else { return nil } + let earliest = observationTimestamp.addingTimeInterval(-Self.maximumTelemetryMatchDistance) + let latest = observationTimestamp.addingTimeInterval(Self.maximumTelemetryMatchDistance) + let startIndex = Self.firstRecordIndex(atOrAfter: earliest, in: records) + var candidates: [CLIProxyAPIUsageRecord] = [] + for record in records[startIndex...] { + guard record.timestamp <= latest else { break } + if tokens.map({ Self.tokensMatch($0, record.tokens) }) ?? true { + candidates.append(record) + } } return candidates.count == 1 ? candidates[0] : nil } + private static func indexUsageRecords( + _ records: [CLIProxyAPIUsageRecord]) -> [String: [CLIProxyAPIUsageRecord]] + { + var recordsByModel: [String: [CLIProxyAPIUsageRecord]] = [:] + for record in records where !record.failed + && record.generate + && record.endpoint.lowercased().contains("/v1/messages") + { + let models = Set([self.canonicalModel(record.alias), self.canonicalModel(record.model)]) + for model in models where !model.isEmpty { + recordsByModel[model, default: []].append(record) + } + } + return recordsByModel.mapValues { records in + records.sorted { $0.timestamp < $1.timestamp } + } + } + + private static func firstRecordIndex( + atOrAfter timestamp: Date, + in records: [CLIProxyAPIUsageRecord]) -> Int + { + var lowerBound = 0 + var upperBound = records.count + while lowerBound < upperBound { + let midpoint = lowerBound + (upperBound - lowerBound) / 2 + if records[midpoint].timestamp < timestamp { + lowerBound = midpoint + 1 + } else { + upperBound = midpoint + } + } + return lowerBound + } + private func authInventoryUpstream( model: String, modelProvider: CostUsageAttribution.ModelProvider) -> CostUsageAttribution.Upstream? diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index dd69ead854..6148281dfa 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -257,7 +257,7 @@ public struct CostUsageFetcher: Sendable { provider: provider, codexHomePath: codexHomePath) let scopedCodexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines) - let shouldMergePiUsage = provider != .codex || scopedCodexHomePath?.isEmpty != false + let shouldMergeGlobalCodexUsage = provider != .codex || scopedCodexHomePath?.isEmpty != false await Self.refreshPricingIfAllowed( options: PricingRefreshOptions( provider: provider, @@ -329,14 +329,14 @@ public struct CostUsageFetcher: Sendable { options: scanOptions, range: range, now: now, - includeClaudeProxy: includeClaudeProxyUsage, + includeClaudeProxy: includeClaudeProxyUsage && shouldMergeGlobalCodexUsage, checkCancellation: checkCancellation) projects = supplemental.projects sessions = supplemental.sessions claudeProxyDaily = supplemental.claudeProxyDaily daily = claudeProxyDaily.map { daily.merged(with: $0) } ?? daily } - if includePiSessions, provider == .claude || (provider == .codex && shouldMergePiUsage) { + if includePiSessions, provider == .claude || (provider == .codex && shouldMergeGlobalCodexUsage) { let piReport = try PiSessionCostScanner.loadDailyReportCancellable( provider: provider, since: since, diff --git a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift index 43489005b0..226dd69564 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift @@ -208,6 +208,42 @@ struct CLIProxyAPIAttributionResolverTests { #expect(attribution.upstream == nil) } + @Test + func `telemetry index isolates the matching model and time window`() { + let timestamp = Date(timeIntervalSince1970: 1_784_179_200) + let unrelated = (0..<10000).map { index in + CLIProxyAPIUsageRecord( + timestamp: timestamp.addingTimeInterval(TimeInterval(index - 5000)), + provider: "openrouter", + model: "unrelated-model", + alias: "unrelated-model", + endpoint: "POST /v1/messages", + authType: "api_key", + requestID: "unrelated-\(index)", + tokens: .init(input: 10, output: 20, total: 30)) + } + let matching = Self.record( + timestamp: timestamp.addingTimeInterval(1), + provider: "codex", + authType: "oauth") + let resolver = CLIProxyAPIAttributionResolver( + observations: [ + .init(sessionID: "session-1", model: "gpt-5.6-sol", timestamp: timestamp), + ], + usageRecords: unrelated + [matching]) + + let attribution = resolver.attribution( + model: "gpt-5.6-sol", + modelProvider: .openAI, + sessionID: "session-1", + timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), + tokens: Self.tokens) + + #expect(attribution.upstream?.provider == "codex") + #expect(attribution.upstream?.model == "gpt-5.6-sol") + #expect(attribution.evidence.contains(.cliProxyUsageTelemetry)) + } + @Test func `model without correlated request does not claim cliproxyapi`() { let resolver = CLIProxyAPIAttributionResolver( diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index 76b328f96d..ca9b80903c 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -794,7 +794,17 @@ extension CostUsageFetcherTests { let codex = try await CostUsageFetcher.loadTokenSnapshot( provider: .codex, now: day, - codexHomePath: env.codexHomeRoot.path, + allowPricingRefresh: false, + includePiSessions: false, + scannerOptions: options) + let scopedHome = env.root.appendingPathComponent("managed-codex-home", isDirectory: true) + try FileManager.default.createDirectory( + at: scopedHome.appendingPathComponent("sessions", isDirectory: true), + withIntermediateDirectories: true) + let scopedCodex = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + codexHomePath: scopedHome.path, allowPricingRefresh: false, includePiSessions: false, scannerOptions: options) @@ -829,6 +839,8 @@ extension CostUsageFetcherTests { executorType: "CodexExecutor"), evidence: [.cliProxyRequestLog, .cliProxyUsageTelemetry, .modelProvider])) #expect(codex.projects.map(\.name) == ["Claude Code via CLIProxyAPI"]) + #expect(scopedCodex.daily.isEmpty) + #expect(scopedCodex.projects.isEmpty) #expect(claude.daily.first?.totalTokens == 70) #expect(claude.daily.first?.modelsUsed == ["claude-sonnet-4-6"]) From 0acb7697a34a2f26ae63bb825a7ad77e20733ab9 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 27 Jul 2026 02:23:53 -0700 Subject: [PATCH 013/183] Cache CLIProxyAPI request logs --- .../CLIProxyAPIAttributionResolver.swift | 97 ++++++++++++++++--- Sources/CodexBarCore/CostUsageFetcher.swift | 1 + .../CostUsage/CostUsageScanner+Claude.swift | 1 + .../CLIProxyAPIAttributionResolverTests.swift | 72 ++++++++++++++ 4 files changed, 158 insertions(+), 13 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift index 95885f2bdc..135f4aa1f9 100644 --- a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift +++ b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift @@ -24,6 +24,7 @@ struct CLIProxyAPIAttributionResolver: Sendable { private static let maxLogPrefixBytes = 2 * 1024 * 1024 private static let maximumRouteMatchDistance: TimeInterval = 60 * 60 private static let maximumTelemetryMatchDistance: TimeInterval = 5 + private static let observationCache = ObservationCache() private let observationsBySessionID: [String: [Observation]] private let usageRecordsByCanonicalModel: [String: [CLIProxyAPIUsageRecord]] @@ -46,11 +47,13 @@ struct CLIProxyAPIAttributionResolver: Sendable { home: URL, cacheRoot: URL? = nil, fileManager: FileManager = .default, + forceReload: Bool = false, checkCancellation: (() throws -> Void)? = nil) throws -> Self { let observations = try self.loadObservations( logDirectory: home.appendingPathComponent("logs", isDirectory: true), fileManager: fileManager, + forceReload: forceReload, checkCancellation: checkCancellation) return Self( observations: observations, @@ -264,6 +267,79 @@ struct CLIProxyAPIAttributionResolver: Sendable { let disabled: Bool? } + private final class ObservationCache: @unchecked Sendable { + private struct Metadata: Equatable { + let modificationDate: Date? + let size: Int? + } + + private struct Entry { + let metadata: Metadata + let observation: Observation? + } + + private let lock = NSLock() + private var entriesByDirectory: [String: [String: Entry]] = [:] + + func load( + logDirectory: URL, + fileManager: FileManager, + forceReload: Bool, + checkCancellation: (() throws -> Void)?, + parse: (URL) -> Observation?) throws -> [Observation] + { + try self.lock.withLock { + let directoryKey = logDirectory.standardizedFileURL.path + let resourceKeys: Set = [ + .contentModificationDateKey, + .fileSizeKey, + .isRegularFileKey, + ] + guard let urls = try? fileManager.contentsOfDirectory( + at: logDirectory, + includingPropertiesForKeys: Array(resourceKeys), + options: [.skipsHiddenFiles]) + else { + self.entriesByDirectory.removeValue(forKey: directoryKey) + return [] + } + + let cachedEntries = forceReload ? [:] : self.entriesByDirectory[directoryKey] ?? [:] + var currentEntries: [String: Entry] = [:] + var observations: [Observation] = [] + for url in urls where url.pathExtension.lowercased() == "log" { + try checkCancellation?() + guard let values = try? url.resourceValues(forKeys: resourceKeys), + values.isRegularFile == true + else { continue } + + let path = url.standardizedFileURL.path + let metadata = Metadata( + modificationDate: values.contentModificationDate, + size: values.fileSize) + let entry = if let cached = cachedEntries[path], + cached.metadata == metadata + { + cached + } else { + Entry(metadata: metadata, observation: parse(url)) + } + currentEntries[path] = entry + if let observation = entry.observation { + observations.append(observation) + } + } + + if currentEntries.isEmpty { + self.entriesByDirectory.removeValue(forKey: directoryKey) + } else { + self.entriesByDirectory[directoryKey] = currentEntries + } + return observations + } + } + } + private static func loadAuthProviders( home: URL, fileManager: FileManager) -> [AuthProvider] @@ -310,22 +386,17 @@ struct CLIProxyAPIAttributionResolver: Sendable { private static func loadObservations( logDirectory: URL, fileManager: FileManager, + forceReload: Bool, checkCancellation: (() throws -> Void)?) throws -> [Observation] { - guard let urls = try? fileManager.contentsOfDirectory( - at: logDirectory, - includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey], - options: [.skipsHiddenFiles]) - else { return [] } - - var observations: [Observation] = [] - for url in urls where url.pathExtension.lowercased() == "log" { - try checkCancellation?() - if let observation = self.parseObservation(url: url) { - observations.append(observation) - } + try self.observationCache.load( + logDirectory: logDirectory, + fileManager: fileManager, + forceReload: forceReload, + checkCancellation: checkCancellation) + { url in + self.parseObservation(url: url) } - return observations } private static func parseObservation(url: URL) -> Observation? { diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 6148281dfa..9822f8e230 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -573,6 +573,7 @@ public struct CostUsageFetcher: Sendable { try CLIProxyAPIAttributionResolver.load( home: home, cacheRoot: options.cacheRoot, + forceReload: options.forceRescan, checkCancellation: checkCancellation) } else { nil diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 950b379e32..2cbb602b3a 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -785,6 +785,7 @@ extension CostUsageScanner { try CLIProxyAPIAttributionResolver.load( home: home, cacheRoot: options.cacheRoot, + forceReload: options.forceRescan, checkCancellation: checkCancellation) } else { nil diff --git a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift index 226dd69564..4330e79fa8 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift @@ -371,6 +371,66 @@ struct CLIProxyAPIAttributionResolverTests { } } + @Test + func `filesystem loader reuses unchanged logs and refreshes changed paths`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-log-cache-\(UUID().uuidString)", isDirectory: true) + let home = root.appendingPathComponent("home", isDirectory: true) + let logs = home.appendingPathComponent("logs", isDirectory: true) + let logURL = logs.appendingPathComponent("request.log") + try fileManager.createDirectory(at: logs, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let requestTimestamp = try #require(CostUsageDateParser.parse("2026-07-16T12:00:00Z")) + let pinnedModificationDate = Date(timeIntervalSince1970: 1_000_000) + let firstLog = Self.requestLog(sessionID: "session-one", timestamp: requestTimestamp) + let secondLog = Self.requestLog(sessionID: "session-two", timestamp: requestTimestamp) + let thirdLog = Self.requestLog(sessionID: "session-new", timestamp: requestTimestamp) + #expect(firstLog.utf8.count == secondLog.utf8.count) + #expect(secondLog.utf8.count == thirdLog.utf8.count) + + try Data(firstLog.utf8).write(to: logURL) + try fileManager.setAttributes( + [.modificationDate: pinnedModificationDate], + ofItemAtPath: logURL.path) + let firstResolver = try CLIProxyAPIAttributionResolver.load(home: home, fileManager: fileManager) + #expect(Self.route(for: "session-one", resolver: firstResolver) == .cliProxyAPI) + + try Data(secondLog.utf8).write(to: logURL) + try fileManager.setAttributes( + [.modificationDate: pinnedModificationDate], + ofItemAtPath: logURL.path) + let cachedResolver = try CLIProxyAPIAttributionResolver.load(home: home, fileManager: fileManager) + #expect(Self.route(for: "session-one", resolver: cachedResolver) == .cliProxyAPI) + #expect(Self.route(for: "session-two", resolver: cachedResolver) == .unknown) + + let forcedResolver = try CLIProxyAPIAttributionResolver.load( + home: home, + fileManager: fileManager, + forceReload: true) + #expect(Self.route(for: "session-one", resolver: forcedResolver) == .unknown) + #expect(Self.route(for: "session-two", resolver: forcedResolver) == .cliProxyAPI) + + try Data(firstLog.utf8).write(to: logURL) + try fileManager.setAttributes( + [.modificationDate: pinnedModificationDate.addingTimeInterval(1)], + ofItemAtPath: logURL.path) + let refreshedResolver = try CLIProxyAPIAttributionResolver.load(home: home, fileManager: fileManager) + #expect(Self.route(for: "session-one", resolver: refreshedResolver) == .cliProxyAPI) + #expect(Self.route(for: "session-two", resolver: refreshedResolver) == .unknown) + + try fileManager.removeItem(at: logURL) + _ = try CLIProxyAPIAttributionResolver.load(home: home, fileManager: fileManager) + try Data(thirdLog.utf8).write(to: logURL) + try fileManager.setAttributes( + [.modificationDate: pinnedModificationDate.addingTimeInterval(1)], + ofItemAtPath: logURL.path) + let recreatedResolver = try CLIProxyAPIAttributionResolver.load(home: home, fileManager: fileManager) + #expect(Self.route(for: "session-one", resolver: recreatedResolver) == .unknown) + #expect(Self.route(for: "session-new", resolver: recreatedResolver) == .cliProxyAPI) + } + @Test func `usage cache never persists source or api key fields`() throws { let fileManager = FileManager.default @@ -706,6 +766,18 @@ struct CLIProxyAPIAttributionResolverTests { cacheCreate: 40, output: 20) + private static func route( + for sessionID: String, + resolver: CLIProxyAPIAttributionResolver) -> CostUsageAttribution.Route + { + resolver.attribution( + model: "gpt-5.6-sol", + modelProvider: .openAI, + sessionID: sessionID, + timestampUnixMs: nil, + tokens: self.tokens).route + } + private static func requestLog(sessionID: String, timestamp: Date) -> String { """ === REQUEST INFO === From b28c18ddc687180ec53eeedf3b84b8483e0ba856 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 27 Jul 2026 02:58:32 -0700 Subject: [PATCH 014/183] Hydrate cached CLIProxyAPI usage --- Sources/CodexBarCore/CostUsageFetcher.swift | 29 +++++++++++++++---- .../CostUsageFetcherCacheSnapshotTests.swift | 25 ++++++++++++++++ 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 9822f8e230..3fe50e354c 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -35,7 +35,7 @@ public struct CostUsageFetcher: Sendable { private let scannerOptions: CostUsageScanner.Options? public init(cacheRoot: URL? = nil) { - self.scannerOptions = cacheRoot.map { CostUsageScanner.Options(cacheRoot: $0) } + self.scannerOptions = Self.defaultScannerOptions(cacheRoot: cacheRoot) } init(scannerOptions: CostUsageScanner.Options) { @@ -193,15 +193,14 @@ public struct CostUsageFetcher: Sendable { self.scannerOptions } - private static func resolvedScannerOptions( + static func resolvedScannerOptions( _ override: CostUsageScanner.Options?, provider: UsageProvider, codexHomePath: String?) -> CostUsageScanner.Options { var options = override ?? CostUsageScanner.Options() if override == nil { - options.cliProxyAPIHome = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".cli-proxy-api", isDirectory: true) + options.cliProxyAPIHome = Self.defaultCLIProxyAPIHome() } if provider == .codex, let codexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines), @@ -213,6 +212,23 @@ public struct CostUsageFetcher: Sendable { return options } + static func defaultScannerOptions( + cacheRoot: URL?, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> CostUsageScanner.Options? + { + cacheRoot.map { + CostUsageScanner.Options( + cacheRoot: $0, + cliProxyAPIHome: Self.defaultCLIProxyAPIHome(homeDirectory: homeDirectory)) + } + } + + private static func defaultCLIProxyAPIHome( + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL + { + homeDirectory.appendingPathComponent(".cli-proxy-api", isDirectory: true) + } + static func loadTokenSnapshot( provider: UsageProvider, environment: [String: String] = ProcessInfo.processInfo.environment, @@ -521,7 +537,10 @@ public struct CostUsageFetcher: Sendable { let until = now let since = Calendar.current.date(byAdding: .day, value: -(clampedHistoryDays - 1), to: now) ?? now let range = CostUsageScanner.CostUsageDayRange(since: since, until: until) - let options = overrideScannerOptions ?? CostUsageScanner.Options() + let options = Self.resolvedScannerOptions( + overrideScannerOptions, + provider: .codex, + codexHomePath: nil) let roots = CostUsageScanner.codexSessionsRoots(options: options) let cache = CostUsageScanner.codexCache( CostUsageCacheIO.load(provider: .codex, cacheRoot: options.cacheRoot), diff --git a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift index bcf9d25777..709ac91e71 100644 --- a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift @@ -3,6 +3,31 @@ import Testing @testable import CodexBarCore struct CostUsageFetcherCacheSnapshotTests { + @Test + func `cached codex token snapshot resolves the default cli proxy home`() { + let options = CostUsageFetcher.resolvedScannerOptions( + nil, + provider: .codex, + codexHomePath: nil) + + #expect(options.cliProxyAPIHome == FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".cli-proxy-api", isDirectory: true)) + } + + @Test + func `cache root scanner options retain the default cli proxy home`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let options = try #require(CostUsageFetcher.defaultScannerOptions( + cacheRoot: env.cacheRoot, + homeDirectory: env.root)) + + #expect(options.cacheRoot == env.cacheRoot) + #expect(options.cliProxyAPIHome == env.root + .appendingPathComponent(".cli-proxy-api", isDirectory: true)) + } + @Test func `cached codex token snapshot loads from existing cache without rescanning`() async throws { let env = try CostUsageTestEnvironment() From ee968596a170629b448a2c60c18b859cea6bd16a Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 27 Jul 2026 03:21:29 -0700 Subject: [PATCH 015/183] Prefer dated CLIProxyAPI request logs --- .../CLIProxyAPIAttributionResolver.swift | 7 +++-- .../CLIProxyAPIAttributionResolverTests.swift | 28 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift index 135f4aa1f9..7f2ed571e2 100644 --- a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift +++ b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift @@ -235,11 +235,14 @@ struct CLIProxyAPIAttributionResolver: Sendable { timestamp: (T) -> Date?) -> T? { let ranked = candidates.compactMap { candidate -> (candidate: T, distance: TimeInterval)? in - guard let date = timestamp(candidate) else { return (candidate, 0) } + guard let date = timestamp(candidate) else { return nil } return (candidate, abs(date.timeIntervalSince(target))) } .sorted { $0.distance < $1.distance } - guard let first = ranked.first else { return nil } + guard let first = ranked.first else { + let undated = candidates.filter { timestamp($0) == nil } + return undated.count == 1 ? undated[0] : nil + } guard ranked.count == 1 || ranked[1].distance > first.distance else { return nil } return first.candidate } diff --git a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift index 4330e79fa8..63ffc1241b 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift @@ -126,6 +126,34 @@ struct CLIProxyAPIAttributionResolverTests { ]) } + @Test + func `dated request log outranks an undated log for telemetry correlation`() { + let timestamp = Date(timeIntervalSince1970: 1_784_179_200) + let resolver = CLIProxyAPIAttributionResolver( + observations: [ + .init(sessionID: "session-1", model: "gpt-5.6-sol", timestamp: nil), + .init(sessionID: "session-1", model: "gpt-5.6-sol", timestamp: timestamp), + ], + usageRecords: [ + Self.record(timestamp: timestamp, provider: "openrouter", authType: "api_key"), + ], + authProviders: [ + .init(provider: "codex", authType: .oauth), + ]) + + let attribution = resolver.attribution( + model: "gpt-5.6-sol", + modelProvider: .openAI, + sessionID: "session-1", + timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), + tokens: Self.tokens) + + #expect(attribution.upstream?.provider == "openrouter") + #expect(attribution.upstream?.authType == .apiKey) + #expect(attribution.evidence.contains(.cliProxyUsageTelemetry)) + #expect(!attribution.evidence.contains(.cliProxyAuthInventory)) + } + @Test func `request telemetry preserves api key authentication type`() { let timestamp = Date(timeIntervalSince1970: 1_784_179_200) From 2372a087ad018d90f36fb999ae27eceda9b5ce38 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 27 Jul 2026 04:06:58 -0700 Subject: [PATCH 016/183] Refresh resolved proxy model pricing --- Sources/CodexBarCore/CostUsageFetcher.swift | 8 ++++++-- .../CostUsageFetcherUnknownModelPricingTests.swift | 12 +++++++----- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 3fe50e354c..0aa8db9c39 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -461,12 +461,16 @@ public struct CostUsageFetcher: Sendable { let unknownModelIDs = Set(daily.data.flatMap { entry in entry.modelBreakdowns?.compactMap { breakdown -> String? in guard breakdown.costUSD == nil else { return nil } + let upstreamModel = breakdown.attribution?.route == .cliProxyAPI + ? breakdown.attribution?.upstream?.model?.trimmingCharacters(in: .whitespacesAndNewlines) + : nil + let pricingModel = upstreamModel.flatMap { $0.isEmpty ? nil : $0 } ?? breakdown.modelName if provider == .codex, - CostUsagePricing.isCodexUnattributedModel(breakdown.modelName) + CostUsagePricing.isCodexUnattributedModel(pricingModel) { return nil } - return breakdown.modelName + return pricingModel } ?? [] }) guard !unknownModelIDs.isEmpty else { return nil } diff --git a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift index dbc1f9f69f..e8e58a4b24 100644 --- a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift @@ -189,10 +189,11 @@ struct CostUsageFetcherUnknownModelPricingTests { } @Test - func `proxy-only fetcher reprices an unknown upstream model after catalog refresh`() async throws { + func `proxy-only fetcher refreshes pricing for the resolved upstream model`() async throws { let environment = try CostUsageTestEnvironment() defer { environment.cleanup() } let day = try environment.makeLocalNoon(year: 2026, month: 7, day: 24) + let alias = "claude-proxy-alias" let freshCatalog = try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(""" { "openai": { @@ -215,7 +216,7 @@ struct CostUsageFetcherUnknownModelPricingTests { "requestId": "request-proxy", "message": [ "id": "message-proxy", - "model": "gpt-new", + "model": "\(alias)", "usage": ["input_tokens": 100, "output_tokens": 10], ], ]])) @@ -231,7 +232,7 @@ struct CostUsageFetcherUnknownModelPricingTests { === HEADERS === X-Claude-Code-Session-Id: session-proxy === REQUEST BODY === - {"model":"gpt-new"} + {"model":"\(alias)"} === API RESPONSE === """ try Data(proxyLog.utf8).write(to: cliProxyLogs.appendingPathComponent("request.log")) @@ -242,7 +243,7 @@ struct CostUsageFetcherUnknownModelPricingTests { provider: "codex", executorType: "CodexExecutor", model: "gpt-new", - alias: "gpt-new", + alias: alias, endpoint: "/v1/messages", authType: "oauth", requestID: "cliproxy-request", @@ -275,7 +276,8 @@ struct CostUsageFetcherUnknownModelPricingTests { data: refreshedCatalog))) let breakdown = try #require(snapshot.daily.first?.modelBreakdowns?.first) - #expect(breakdown.modelName == "gpt-new") + #expect(breakdown.modelName == alias) + #expect(breakdown.attribution?.upstream?.model == "gpt-new") #expect(abs((breakdown.costUSD ?? 0) - 0.00028) < 0.0000001) } } From d20f52a9ad16628e9c59d134d10f266e838aee91 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 27 Jul 2026 04:57:25 -0700 Subject: [PATCH 017/183] Refresh proxy pricing by provider --- Sources/CodexBarCore/CostUsageFetcher.swift | 90 ++++++++++++----- ...UsageFetcherUnknownModelPricingTests.swift | 98 +++++++++++++++++++ 2 files changed, 164 insertions(+), 24 deletions(-) diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 0aa8db9c39..d6bb55bc0e 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -443,8 +443,7 @@ public struct CostUsageFetcher: Sendable { } private struct UnknownPricingRefreshRequest: Sendable { - let providerID: String - let modelIDs: Set + let modelIDsByProviderID: [String: Set] let now: Date let cacheRoot: URL? let client: ModelsDevClient @@ -458,9 +457,10 @@ public struct CostUsageFetcher: Sendable { client: ModelsDevClient) -> UnknownPricingRefreshRequest? { guard provider == .codex || provider == .claude else { return nil } - let unknownModelIDs = Set(daily.data.flatMap { entry in - entry.modelBreakdowns?.compactMap { breakdown -> String? in - guard breakdown.costUSD == nil else { return nil } + var modelIDsByProviderID: [String: Set] = [:] + for entry in daily.data { + for breakdown in entry.modelBreakdowns ?? [] { + guard breakdown.costUSD == nil else { continue } let upstreamModel = breakdown.attribution?.route == .cliProxyAPI ? breakdown.attribution?.upstream?.model?.trimmingCharacters(in: .whitespacesAndNewlines) : nil @@ -468,42 +468,84 @@ public struct CostUsageFetcher: Sendable { if provider == .codex, CostUsagePricing.isCodexUnattributedModel(pricingModel) { - return nil + continue } - return pricingModel - } ?? [] - }) - guard !unknownModelIDs.isEmpty else { return nil } + let providerID = Self.modelsDevProviderID( + pricingModel: pricingModel, + attribution: breakdown.attribution, + fallbackProvider: provider, + cacheRoot: cacheRoot) + modelIDsByProviderID[providerID, default: []].insert(pricingModel) + } + } + guard !modelIDsByProviderID.isEmpty else { return nil } return UnknownPricingRefreshRequest( - providerID: provider == .codex ? "openai" : "anthropic", - modelIDs: unknownModelIDs, + modelIDsByProviderID: modelIDsByProviderID, now: now, cacheRoot: cacheRoot, client: client) } + private static func modelsDevProviderID( + pricingModel: String, + attribution: CostUsageAttribution?, + fallbackProvider: UsageProvider, + cacheRoot: URL?) -> String + { + if attribution?.route == .cliProxyAPI { + switch attribution?.upstream?.executorType?.lowercased() { + case "codexexecutor", "openaicompatexecutor": + return "openai" + case "claudeexecutor": + return "anthropic" + case "geminiexecutor": + return "google" + default: + break + } + } + + return switch CostUsagePricing.modelProvider( + for: pricingModel, + modelsDevCacheRoot: cacheRoot) + { + case .openAI: "openai" + case .anthropic: "anthropic" + case .google: "google" + case .unknown: fallbackProvider == .codex ? "openai" : "anthropic" + } + } + private static func refreshUnknownPricingIfNeeded( _ request: UnknownPricingRefreshRequest, inBackground: Bool) async -> Bool { if inBackground { Task.detached(priority: .utility) { - _ = await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( - providerID: request.providerID, - modelIDs: request.modelIDs, - now: request.now, - cacheRoot: request.cacheRoot, - client: request.client) + for providerID in request.modelIDsByProviderID.keys.sorted() { + _ = await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( + providerID: providerID, + modelIDs: request.modelIDsByProviderID[providerID] ?? [], + now: request.now, + cacheRoot: request.cacheRoot, + client: request.client) + } } return false } - return await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( - providerID: request.providerID, - modelIDs: request.modelIDs, - now: request.now, - cacheRoot: request.cacheRoot, - client: request.client) == .pricingAvailable + + var pricingAvailable = false + for providerID in request.modelIDsByProviderID.keys.sorted() { + let result = await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded( + providerID: providerID, + modelIDs: request.modelIDsByProviderID[providerID] ?? [], + now: request.now, + cacheRoot: request.cacheRoot, + client: request.client) + pricingAvailable = result == .pricingAvailable || pricingAvailable + } + return pricingAvailable } static func loadCachedCodexTokenSnapshot( diff --git a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift index e8e58a4b24..5c0787f635 100644 --- a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift @@ -280,6 +280,104 @@ struct CostUsageFetcherUnknownModelPricingTests { #expect(breakdown.attribution?.upstream?.model == "gpt-new") #expect(abs((breakdown.costUSD ?? 0) - 0.00028) < 0.0000001) } + + @Test + func `claude fetch refreshes OpenAI compatible proxy pricing under the resolved provider`() async throws { + let environment = try CostUsageTestEnvironment() + defer { environment.cleanup() } + let day = try environment.makeLocalNoon(year: 2026, month: 7, day: 24) + let alias = "claude-proxy-alias" + let staleCatalog = try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(""" + { + "openai": { + "id": "openai", + "models": { "gpt-old": { "id": "gpt-old", "cost": { "input": 1, "output": 4 } } } + }, + "anthropic": { + "id": "anthropic", + "models": { "claude-old": { "id": "claude-old", "cost": { "input": 3, "output": 15 } } } + } + } + """.utf8)) + ModelsDevCache.save( + catalog: staleCatalog, + fetchedAt: day.addingTimeInterval(-901), + cacheRoot: environment.cacheRoot) + + _ = try environment.writeClaudeProjectFile( + relativePath: "proxy/openrouter-unknown-model.jsonl", + contents: environment.jsonl([[ + "type": "assistant", + "timestamp": environment.isoString(for: day), + "sessionId": "session-openrouter", + "requestId": "request-openrouter", + "message": [ + "id": "message-openrouter", + "model": "\(alias)", + "usage": ["input_tokens": 100, "output_tokens": 10], + ], + ]])) + let cliProxyHome = environment.root.appendingPathComponent("cli-proxy-api", isDirectory: true) + let cliProxyLogs = cliProxyHome.appendingPathComponent("logs", isDirectory: true) + try FileManager.default.createDirectory(at: cliProxyLogs, withIntermediateDirectories: true) + let proxyLog = """ + === REQUEST INFO === + URL: /v1/messages + Timestamp: \(environment.isoString(for: day)) + === HEADERS === + X-Claude-Code-Session-Id: session-openrouter + === REQUEST BODY === + {"model":"\(alias)"} + === API RESPONSE === + """ + try Data(proxyLog.utf8).write(to: cliProxyLogs.appendingPathComponent("request.log")) + CLIProxyAPIUsageCacheIO.merge( + [ + CLIProxyAPIUsageRecord( + timestamp: day, + provider: "openrouter", + executorType: "OpenAICompatExecutor", + model: "gpt-new", + alias: alias, + endpoint: "/v1/messages", + authType: "api_key", + requestID: "cliproxy-openrouter-request", + tokens: .init(input: 100, output: 10, total: 110)), + ], + cacheRoot: environment.cacheRoot, + now: day) + let options = CostUsageScanner.Options( + claudeProjectsRoots: [environment.claudeProjectsRoot], + cacheRoot: environment.cacheRoot, + cliProxyAPIHome: cliProxyHome) + let refreshedCatalog = Data(""" + { + "openai": { + "id": "openai", + "models": { "gpt-new": { "id": "gpt-new", "cost": { "input": 2, "output": 8 } } } + }, + "anthropic": { + "id": "anthropic", + "models": { "claude-new": { "id": "claude-new", "cost": { "input": 3, "output": 15 } } } + } + } + """.utf8) + + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .claude, + now: day, + refreshPricingInBackground: false, + includePiSessions: false, + scannerOptions: options, + modelsDevClient: ModelsDevClient(transport: CostUsageFetcherModelsDevTransport( + data: refreshedCatalog))) + + let breakdown = try #require(snapshot.daily.first?.modelBreakdowns?.first) + #expect(breakdown.modelName == alias) + #expect(breakdown.attribution?.upstream?.provider == "openrouter") + #expect(breakdown.attribution?.upstream?.model == "gpt-new") + #expect(abs((breakdown.costUSD ?? 0) - 0.00028) < 0.0000001) + } } private struct UnknownModelPricingFixture { From c82e0d5f9c9111d14e0397e1c1cfa220d899ba33 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 27 Jul 2026 05:18:09 -0700 Subject: [PATCH 018/183] Resolve proxy model vendors after refresh --- Sources/CodexBarCore/CostUsageFetcher.swift | 41 ++++++++++--------- ...UsageFetcherUnknownModelPricingTests.swift | 18 +++++--- 2 files changed, 34 insertions(+), 25 deletions(-) diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index d6bb55bc0e..3717b1d531 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -470,12 +470,14 @@ public struct CostUsageFetcher: Sendable { { continue } - let providerID = Self.modelsDevProviderID( + let providerIDs = Self.modelsDevProviderIDs( pricingModel: pricingModel, attribution: breakdown.attribution, fallbackProvider: provider, cacheRoot: cacheRoot) - modelIDsByProviderID[providerID, default: []].insert(pricingModel) + for providerID in providerIDs { + modelIDsByProviderID[providerID, default: []].insert(pricingModel) + } } } guard !modelIDsByProviderID.isEmpty else { return nil } @@ -487,34 +489,35 @@ public struct CostUsageFetcher: Sendable { client: client) } - private static func modelsDevProviderID( + private static func modelsDevProviderIDs( pricingModel: String, attribution: CostUsageAttribution?, fallbackProvider: UsageProvider, - cacheRoot: URL?) -> String + cacheRoot: URL?) -> Set { - if attribution?.route == .cliProxyAPI { - switch attribution?.upstream?.executorType?.lowercased() { - case "codexexecutor", "openaicompatexecutor": - return "openai" - case "claudeexecutor": - return "anthropic" - case "geminiexecutor": - return "google" - default: - break - } - } - - return switch CostUsagePricing.modelProvider( + let knownProviderID: String? = switch CostUsagePricing.modelProvider( for: pricingModel, modelsDevCacheRoot: cacheRoot) { case .openAI: "openai" case .anthropic: "anthropic" case .google: "google" - case .unknown: fallbackProvider == .codex ? "openai" : "anthropic" + case .unknown: nil + } + if let knownProviderID { + return [knownProviderID] + } + + if attribution?.route == .cliProxyAPI { + return switch attribution?.upstream?.executorType?.lowercased() { + case "codexexecutor": ["openai"] + case "claudeexecutor": ["anthropic"] + case "geminiexecutor": ["google"] + case "openaicompatexecutor": ["openai", "anthropic", "google"] + default: [fallbackProvider == .codex ? "openai" : "anthropic"] + } } + return [fallbackProvider == .codex ? "openai" : "anthropic"] } private static func refreshUnknownPricingIfNeeded( diff --git a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift index 5c0787f635..e8808734bf 100644 --- a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift @@ -281,8 +281,14 @@ struct CostUsageFetcherUnknownModelPricingTests { #expect(abs((breakdown.costUSD ?? 0) - 0.00028) < 0.0000001) } - @Test - func `claude fetch refreshes OpenAI compatible proxy pricing under the resolved provider`() async throws { + @Test(arguments: [ + ("gpt-new", 0.00028), + ("claude-new", 0.00045), + ]) + func `claude fetch resolves OpenAI compatible proxy pricing across model vendors`( + upstreamModel: String, + expectedCost: Double) async throws + { let environment = try CostUsageTestEnvironment() defer { environment.cleanup() } let day = try environment.makeLocalNoon(year: 2026, month: 7, day: 24) @@ -337,11 +343,11 @@ struct CostUsageFetcherUnknownModelPricingTests { timestamp: day, provider: "openrouter", executorType: "OpenAICompatExecutor", - model: "gpt-new", + model: upstreamModel, alias: alias, endpoint: "/v1/messages", authType: "api_key", - requestID: "cliproxy-openrouter-request", + requestID: "cliproxy-openrouter-\(upstreamModel)", tokens: .init(input: 100, output: 10, total: 110)), ], cacheRoot: environment.cacheRoot, @@ -375,8 +381,8 @@ struct CostUsageFetcherUnknownModelPricingTests { let breakdown = try #require(snapshot.daily.first?.modelBreakdowns?.first) #expect(breakdown.modelName == alias) #expect(breakdown.attribution?.upstream?.provider == "openrouter") - #expect(breakdown.attribution?.upstream?.model == "gpt-new") - #expect(abs((breakdown.costUSD ?? 0) - 0.00028) < 0.0000001) + #expect(breakdown.attribution?.upstream?.model == upstreamModel) + #expect(abs((breakdown.costUSD ?? 0) - expectedCost) < 0.0000001) } } From a87a694acb1015f397ce120330f9c2d06859f357 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 27 Jul 2026 08:13:56 -0700 Subject: [PATCH 019/183] Keep proxy auth inventory ambiguous --- .../CLIProxyAPIAttributionResolver.swift | 16 ++++++---- .../CLIProxyAPIAttributionResolverTests.swift | 31 +++++++++++++++++++ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift index 7f2ed571e2..1951b4bddd 100644 --- a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift +++ b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift @@ -203,10 +203,11 @@ struct CLIProxyAPIAttributionResolver: Sendable { !self.observationsBySessionID.isEmpty, !self.hasConfiguredOpenAIAPIUpstream else { return nil } - let providers = Array(Set(self.authProviders.filter { - $0.provider.caseInsensitiveCompare("codex") == .orderedSame - })) - guard providers.count == 1, let provider = providers.first else { return nil } + let providers = Array(Set(self.authProviders)) + guard providers.count == 1, + let provider = providers.first, + provider.provider.caseInsensitiveCompare("codex") == .orderedSame + else { return nil } return CostUsageAttribution.Upstream( provider: provider.provider, authType: provider.authType, @@ -362,9 +363,12 @@ struct CLIProxyAPIAttributionResolver: Sendable { let auth = try? decoder.decode(AuthFile.self, from: data), auth.disabled != true, let rawType = auth.type?.trimmingCharacters(in: .whitespacesAndNewlines), - rawType.caseInsensitiveCompare("codex") == .orderedSame + !rawType.isEmpty else { return nil } - return AuthProvider(provider: "codex", authType: .oauth) + let isCodex = rawType.caseInsensitiveCompare("codex") == .orderedSame + return AuthProvider( + provider: isCodex ? "codex" : rawType.lowercased(), + authType: isCodex ? .oauth : .unknown) } return Array(Set(providers)) } diff --git a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift index 63ffc1241b..afd774b42e 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift @@ -94,6 +94,37 @@ struct CLIProxyAPIAttributionResolverTests { #expect(attribution.evidence == [.modelProvider]) } + @Test + func `codex auth inventory stays ambiguous with another active provider`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-auth-inventory-\(UUID().uuidString)", isDirectory: true) + let home = root.appendingPathComponent("home", isDirectory: true) + let logs = home.appendingPathComponent("logs", isDirectory: true) + try fileManager.createDirectory(at: logs, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let timestamp = Date(timeIntervalSince1970: 1_784_179_200) + try Data(Self.requestLog(sessionID: "session-1", timestamp: timestamp).utf8) + .write(to: logs.appendingPathComponent("request.log")) + try Data(#"{"type":"codex"}"#.utf8) + .write(to: home.appendingPathComponent("codex.json")) + try Data(#"{"type":"openrouter"}"#.utf8) + .write(to: home.appendingPathComponent("openrouter.json")) + + let resolver = try CLIProxyAPIAttributionResolver.load(home: home, fileManager: fileManager) + let attribution = resolver.attribution( + model: "gpt-5.6-sol", + modelProvider: .openAI, + sessionID: "session-1", + timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), + tokens: Self.tokens) + + #expect(attribution.route == .cliProxyAPI) + #expect(attribution.upstream == nil) + #expect(attribution.evidence == [.cliProxyRequestLog, .modelProvider]) + } + @Test func `request telemetry identifies exact codex oauth upstream`() { let timestamp = Date(timeIntervalSince1970: 1_784_179_200) From 3bf86e542d3cbfeaf33eaf47132e97f9502d67eb Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 27 Jul 2026 23:28:20 -0700 Subject: [PATCH 020/183] Stabilize attribution breakdown ordering --- Sources/CodexBarCore/CostUsageModels.swift | 31 +++++++++--- .../CostUsageDailyReportMergeTests.swift | 50 +++++++++++++++++++ 2 files changed, 75 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index 153d8d10aa..eda95f1bb5 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -889,15 +889,34 @@ extension CostUsageDailyReport { if lhs.modelName != rhs.modelName { return lhs.modelName > rhs.modelName } - let lhsAttribution = lhs.attribution.map { - "\($0.client.rawValue):\($0.route.rawValue):\($0.upstream?.provider ?? "")" - } ?? "" - let rhsAttribution = rhs.attribution.map { - "\($0.client.rawValue):\($0.route.rawValue):\($0.upstream?.provider ?? "")" - } ?? "" + let lhsAttribution = self.attributionSortKey(lhs.attribution) + let rhsAttribution = self.attributionSortKey(rhs.attribution) return lhsAttribution > rhsAttribution } } + + private static func attributionSortKey(_ attribution: CostUsageAttribution?) -> String { + guard let attribution else { return "" } + + let upstream = attribution.upstream + let fields = [ + attribution.client.rawValue, + attribution.route.rawValue, + attribution.modelProvider.rawValue, + upstream == nil ? "0" : "1", + upstream?.provider ?? "", + upstream?.authType.rawValue ?? "", + upstream?.model == nil ? "0" : "1", + upstream?.model ?? "", + upstream?.executorType == nil ? "0" : "1", + upstream?.executorType ?? "", + String(attribution.evidence.count), + ] + attribution.evidence.map(\.rawValue) + + return fields + .map { "\($0.utf8.count):\($0)" } + .joined() + } } public struct CostUsageSessionReport: Sendable, Decodable { diff --git a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift index 8d13ffa347..9b2eca09f6 100644 --- a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift +++ b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift @@ -179,6 +179,56 @@ struct CostUsageDailyReportMergeTests { #expect(abs((merged.summary?.totalCostUSD ?? 0) - 0.70) < 0.000001) } + @Test + func `merged report uses complete attribution to order otherwise equal model breakdowns`() throws { + let apiKeyAttribution = CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .openAI, + upstream: .init( + provider: "codex", + authType: .apiKey, + model: "gpt-5.4", + executorType: "codex"), + evidence: [.cliProxyRequestLog]) + let oauthAttribution = CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .openAI, + upstream: .init( + provider: "codex", + authType: .oauth, + model: "gpt-5.4", + executorType: "codex"), + evidence: [.cliProxyRequestLog]) + let report = CostUsageDailyReport( + data: [ + CostUsageDailyReport.Entry( + date: "2026-07-24", + inputTokens: nil, + outputTokens: nil, + totalTokens: 20, + costUSD: 0.2, + modelsUsed: ["gpt-5.4"], + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.4", + costUSD: 0.1, + totalTokens: 10, + attribution: apiKeyAttribution), + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.4", + costUSD: 0.1, + totalTokens: 10, + attribution: oauthAttribution), + ]), + ], + summary: nil) + + let breakdowns = try #require(CostUsageDailyReport.merged([report]).data.first?.modelBreakdowns) + #expect(breakdowns.map(\.attribution) == [oauthAttribution, apiKeyAttribution]) + } + @Test func `merged report includes derived totals when another same day entry has explicit total`() { let explicit = CostUsageDailyReport( From c75c92f9524ddb41dc0182a79ec77ade085982c6 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 27 Jul 2026 23:43:27 -0700 Subject: [PATCH 021/183] Stabilize dashboard attribution ranks --- Sources/CodexBar/SpendDashboardModel.swift | 7 +- Sources/CodexBarCore/CostUsageModels.swift | 47 +++++----- .../CostUsageDailyReportMergeTests.swift | 93 +++++++++++++++++++ .../SpendDashboardProxyAttributionTests.swift | 3 +- 4 files changed, 123 insertions(+), 27 deletions(-) diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index 006661c9d2..bdc7d891f9 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -368,7 +368,12 @@ struct SpendDashboardModel: Equatable, Sendable { if lhs.providerName != rhs.providerName { return lhs.providerName < rhs.providerName } - return lhs.modelName < rhs.modelName + if lhs.modelName != rhs.modelName { + return lhs.modelName < rhs.modelName + } + let lhsAttribution = lhs.attribution?.deterministicSortKey ?? "" + let rhsAttribution = rhs.attribution?.deterministicSortKey ?? "" + return lhsAttribution < rhsAttribution } } .enumerated() diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index eda95f1bb5..12b33478bd 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -375,6 +375,26 @@ public struct CostUsageAttribution: Sendable, Codable, Equatable, Hashable { self.upstream = upstream self.evidence = evidence } + + package var deterministicSortKey: String { + let fields = [ + self.client.rawValue, + self.route.rawValue, + self.modelProvider.rawValue, + self.upstream == nil ? "0" : "1", + self.upstream?.provider ?? "", + self.upstream?.authType.rawValue ?? "", + self.upstream?.model == nil ? "0" : "1", + self.upstream?.model ?? "", + self.upstream?.executorType == nil ? "0" : "1", + self.upstream?.executorType ?? "", + String(self.evidence.count), + ] + self.evidence.map(\.rawValue) + + return fields + .map { "\($0.utf8.count):\($0)" } + .joined() + } } public struct CostUsageDailyReport: Sendable, Decodable { @@ -889,34 +909,11 @@ extension CostUsageDailyReport { if lhs.modelName != rhs.modelName { return lhs.modelName > rhs.modelName } - let lhsAttribution = self.attributionSortKey(lhs.attribution) - let rhsAttribution = self.attributionSortKey(rhs.attribution) + let lhsAttribution = lhs.attribution?.deterministicSortKey ?? "" + let rhsAttribution = rhs.attribution?.deterministicSortKey ?? "" return lhsAttribution > rhsAttribution } } - - private static func attributionSortKey(_ attribution: CostUsageAttribution?) -> String { - guard let attribution else { return "" } - - let upstream = attribution.upstream - let fields = [ - attribution.client.rawValue, - attribution.route.rawValue, - attribution.modelProvider.rawValue, - upstream == nil ? "0" : "1", - upstream?.provider ?? "", - upstream?.authType.rawValue ?? "", - upstream?.model == nil ? "0" : "1", - upstream?.model ?? "", - upstream?.executorType == nil ? "0" : "1", - upstream?.executorType ?? "", - String(attribution.evidence.count), - ] + attribution.evidence.map(\.rawValue) - - return fields - .map { "\($0.utf8.count):\($0)" } - .joined() - } } public struct CostUsageSessionReport: Sendable, Decodable { diff --git a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift index 9b2eca09f6..288676ffe1 100644 --- a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift +++ b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift @@ -229,6 +229,99 @@ struct CostUsageDailyReportMergeTests { #expect(breakdowns.map(\.attribution) == [oauthAttribution, apiKeyAttribution]) } + @Test + func `attribution sort key includes every distinguishable field`() { + let attributions = [ + CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .openAI, + upstream: .init( + provider: "codex", + authType: .oauth, + model: "gpt-5.4", + executorType: "codex"), + evidence: [.cliProxyRequestLog, .modelProvider]), + CostUsageAttribution( + client: .claudeCode, + route: .unknown, + modelProvider: .openAI, + upstream: .init( + provider: "codex", + authType: .oauth, + model: "gpt-5.4", + executorType: "codex"), + evidence: [.cliProxyRequestLog, .modelProvider]), + CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .anthropic, + upstream: .init( + provider: "codex", + authType: .oauth, + model: "gpt-5.4", + executorType: "codex"), + evidence: [.cliProxyRequestLog, .modelProvider]), + CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .openAI, + evidence: [.cliProxyRequestLog, .modelProvider]), + CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .openAI, + upstream: .init( + provider: "openrouter", + authType: .oauth, + model: "gpt-5.4", + executorType: "codex"), + evidence: [.cliProxyRequestLog, .modelProvider]), + CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .openAI, + upstream: .init( + provider: "codex", + authType: .apiKey, + model: "gpt-5.4", + executorType: "codex"), + evidence: [.cliProxyRequestLog, .modelProvider]), + CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .openAI, + upstream: .init( + provider: "codex", + authType: .oauth, + model: "gpt-5.3", + executorType: "codex"), + evidence: [.cliProxyRequestLog, .modelProvider]), + CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .openAI, + upstream: .init( + provider: "codex", + authType: .oauth, + model: "gpt-5.4", + executorType: "openai"), + evidence: [.cliProxyRequestLog, .modelProvider]), + CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .openAI, + upstream: .init( + provider: "codex", + authType: .oauth, + model: "gpt-5.4", + executorType: "codex"), + evidence: [.modelProvider, .cliProxyRequestLog]), + ] + + #expect(Set(attributions.map(\.deterministicSortKey)).count == attributions.count) + } + @Test func `merged report includes derived totals when another same day entry has explicit total`() { let explicit = CostUsageDailyReport( diff --git a/Tests/CodexBarTests/SpendDashboardProxyAttributionTests.swift b/Tests/CodexBarTests/SpendDashboardProxyAttributionTests.swift index dfad26f70b..55e22cdf31 100644 --- a/Tests/CodexBarTests/SpendDashboardProxyAttributionTests.swift +++ b/Tests/CodexBarTests/SpendDashboardProxyAttributionTests.swift @@ -148,6 +148,7 @@ struct SpendDashboardProxyAttributionTests { let rows = group.models #expect(rows.count == 2) #expect(Set(rows.map(\.id)).count == 2) - #expect(Set(rows.compactMap(\.attribution)) == [inventoryAttribution, telemetryAttribution]) + #expect(rows.map(\.attribution) == [inventoryAttribution, telemetryAttribution]) + #expect(rows.map(\.rank) == [1, 2]) } } From f933bdbb63b53a53751d912dc53d5d529a7f7aeb Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 28 Jul 2026 00:06:22 -0700 Subject: [PATCH 022/183] Retry failed telemetry persistence --- .../CLIProxyAPIUsageTelemetry.swift | 34 +++++++++++++++++++ .../CLIProxyAPIAttributionResolverTests.swift | 20 ++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 16d1d430a5..cc7c90315b 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -322,10 +322,27 @@ private actor CLIProxyAPIUsageCollectionGate { } } +private actor CLIProxyAPIUsageRetryBuffer { + private var recordsByCachePath: [String: [CLIProxyAPIUsageRecord]] = [:] + + func records(for cachePath: String) -> [CLIProxyAPIUsageRecord] { + self.recordsByCachePath[cachePath] ?? [] + } + + func store(_ records: [CLIProxyAPIUsageRecord], for cachePath: String) { + self.recordsByCachePath[cachePath] = records + } + + func clear(for cachePath: String) { + self.recordsByCachePath.removeValue(forKey: cachePath) + } +} + public enum CLIProxyAPIUsageCollector { private static let maximumBatches = 10 private static let batchSize = 100 private static let collectionGate = CLIProxyAPIUsageCollectionGate() + private static let retryBuffer = CLIProxyAPIUsageRetryBuffer() public static func collect( cacheRoot: URL? = nil, @@ -353,9 +370,26 @@ public enum CLIProxyAPIUsageCollector { { do { var added = 0 + let cachePath = CLIProxyAPIUsageCacheIO.cacheFileURL(cacheRoot: cacheRoot) + .standardizedFileURL.path + let pendingRecords = await self.retryBuffer.records(for: cachePath) + if !pendingRecords.isEmpty { + guard let pendingAdded = CLIProxyAPIUsageCacheIO.merge( + pendingRecords, + cacheRoot: cacheRoot) + else { + return .failed("Could not save CLIProxyAPI usage telemetry.") + } + added += pendingAdded + await self.retryBuffer.clear(for: cachePath) + } + for _ in 0.. Date: Tue, 28 Jul 2026 00:13:06 -0700 Subject: [PATCH 023/183] Split telemetry retry test --- .../CLIProxyAPIAttributionResolverTests.swift | 55 ----------------- .../CLIProxyAPIUsageCollectorTests.swift | 60 +++++++++++++++++++ 2 files changed, 60 insertions(+), 55 deletions(-) create mode 100644 Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift diff --git a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift index 754ff210a1..c70dfe5dcf 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift @@ -766,61 +766,6 @@ struct CLIProxyAPIAttributionResolverTests { #expect(after == before) } - @Test - func `usage collector retries a popped batch after cache write failure`() async throws { - let fileManager = FileManager.default - let cacheRoot = fileManager.temporaryDirectory - .appendingPathComponent("cliproxy-blocked-\(UUID().uuidString)", isDirectory: false) - try Data("not-a-directory".utf8).write(to: cacheRoot) - defer { try? fileManager.removeItem(at: cacheRoot) } - let timestamp = try #require(CostUsageDateParser.parse("2026-07-16T12:00:00Z")) - let record = CLIProxyAPIUsageRecord( - timestamp: timestamp, - provider: "codex", - model: "gpt-5.6-sol", - alias: "gpt-5.6-sol", - endpoint: "POST /v1/messages", - authType: "oauth", - requestID: "request-1", - tokens: .init(input: 10, output: 20, total: 30)) - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - let data = try encoder.encode([record]) - let client = CLIProxyAPIUsageQueueClient( - settings: .init(managementKey: "management-secret"), - dataLoader: { request in - let url = try #require(request.url) - let response = try #require(HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: nil, - headerFields: nil)) - return (data, response) - }) - - let result = await CLIProxyAPIUsageCollector.collect(cacheRoot: cacheRoot, client: client) - - #expect(result == .failed("Could not save CLIProxyAPI usage telemetry.")) - try fileManager.removeItem(at: cacheRoot) - let retryClient = CLIProxyAPIUsageQueueClient( - settings: .init(managementKey: "management-secret"), - dataLoader: { request in - #expect(CLIProxyAPIUsageCacheIO.load(cacheRoot: cacheRoot).map(\.requestID) == ["request-1"]) - let url = try #require(request.url) - let response = try #require(HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: nil, - headerFields: nil)) - return (Data("[]".utf8), response) - }) - - let retryResult = await CLIProxyAPIUsageCollector.collect(cacheRoot: cacheRoot, client: retryClient) - - #expect(retryResult == .collected(1)) - #expect(CLIProxyAPIUsageCacheIO.load(cacheRoot: cacheRoot).map(\.requestID) == ["request-1"]) - } - @Test func `plain http management url is limited to loopback`() { #expect(CLIProxyAPIConnectionSettings( diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift new file mode 100644 index 0000000000..c0667030f3 --- /dev/null +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift @@ -0,0 +1,60 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CLIProxyAPIUsageCollectorTests { + @Test + func `retries a popped batch after cache write failure`() async throws { + let fileManager = FileManager.default + let cacheRoot = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-blocked-\(UUID().uuidString)", isDirectory: false) + try Data("not-a-directory".utf8).write(to: cacheRoot) + defer { try? fileManager.removeItem(at: cacheRoot) } + let timestamp = try #require(CostUsageDateParser.parse("2026-07-16T12:00:00Z")) + let record = CLIProxyAPIUsageRecord( + timestamp: timestamp, + provider: "codex", + model: "gpt-5.6-sol", + alias: "gpt-5.6-sol", + endpoint: "POST /v1/messages", + authType: "oauth", + requestID: "request-1", + tokens: .init(input: 10, output: 20, total: 30)) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode([record]) + let client = CLIProxyAPIUsageQueueClient( + settings: .init(managementKey: "management-secret"), + dataLoader: { request in + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + return (data, response) + }) + + let result = await CLIProxyAPIUsageCollector.collect(cacheRoot: cacheRoot, client: client) + + #expect(result == .failed("Could not save CLIProxyAPI usage telemetry.")) + try fileManager.removeItem(at: cacheRoot) + let retryClient = CLIProxyAPIUsageQueueClient( + settings: .init(managementKey: "management-secret"), + dataLoader: { request in + #expect(CLIProxyAPIUsageCacheIO.load(cacheRoot: cacheRoot).map(\.requestID) == ["request-1"]) + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + return (Data("[]".utf8), response) + }) + + let retryResult = await CLIProxyAPIUsageCollector.collect(cacheRoot: cacheRoot, client: retryClient) + + #expect(retryResult == .collected(1)) + #expect(CLIProxyAPIUsageCacheIO.load(cacheRoot: cacheRoot).map(\.requestID) == ["request-1"]) + } +} From 30604c7c949fe44e2c6c24d9379dc323bf88e4d4 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 28 Jul 2026 00:25:04 -0700 Subject: [PATCH 024/183] Harden telemetry collection --- .../PreferencesSpendDashboardPane.swift | 4 +- .../CLIProxyAPIUsageTelemetry.swift | 121 ++++++++++++++---- .../CLIProxyAPIAttributionResolverTests.swift | 5 +- .../CLIProxyAPIUsageCollectorTests.swift | 22 +++- 4 files changed, 119 insertions(+), 33 deletions(-) diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index a91ffc5b2a..4725ec3932 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -225,7 +225,7 @@ struct SpendDashboardPane: View { Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 10) { GridRow { - Text("Server URL") + Text("Local server URL") .foregroundStyle(.secondary) TextField( CLIProxyAPIConnectionSettings.defaultBaseURL, @@ -295,7 +295,7 @@ struct SpendDashboardPane: View { baseURL: self.cliProxyAPIBaseURL, managementKey: enteredKey.isEmpty ? existingKey : enteredKey) guard configuration.isConfigured else { - self.cliProxyAPIStatus = "Enter a valid URL and management key." + self.cliProxyAPIStatus = "Enter a loopback URL and management key." return } guard CLIProxyAPIConnectionSettingsStore.save(configuration) else { diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index cc7c90315b..378b7a8032 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -235,6 +235,80 @@ enum CLIProxyAPIUsageCacheIO { } } +enum CLIProxyAPIUsagePendingIO { + private struct PendingBatch: Codable { + var version: Int = 1 + var records: [CLIProxyAPIUsageRecord] = [] + } + + static func load(pendingRoot: URL? = nil) -> [CLIProxyAPIUsageRecord]? { + let url = self.pendingFileURL(pendingRoot: pendingRoot) + guard FileManager.default.fileExists(atPath: url.path) else { return [] } + guard let data = try? Data(contentsOf: url), + let pendingBatch = try? self.decoder.decode(PendingBatch.self, from: data), + pendingBatch.version == 1 + else { return nil } + return pendingBatch.records + } + + static func save(_ records: [CLIProxyAPIUsageRecord], pendingRoot: URL? = nil) -> Bool { + let url = self.pendingFileURL(pendingRoot: pendingRoot) + do { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + let data = try self.encoder.encode(PendingBatch(records: records)) + try data.write(to: url, options: [.atomic]) + return true + } catch { + return false + } + } + + static func clear(pendingRoot: URL? = nil) -> Bool { + let url = self.pendingFileURL(pendingRoot: pendingRoot) + guard FileManager.default.fileExists(atPath: url.path) else { return true } + do { + try FileManager.default.removeItem(at: url) + return true + } catch { + return false + } + } + + static func pendingFileURL(pendingRoot: URL? = nil) -> URL { + let root = pendingRoot ?? FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask).first! + .appendingPathComponent("CodexBar", isDirectory: true) + return root + .appendingPathComponent("cost-usage", isDirectory: true) + .appendingPathComponent("cliproxyapi-pending-v1.json", isDirectory: false) + } + + private static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + guard let date = CostUsageDateParser.parse(value) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid pending CLIProxyAPI usage timestamp.") + } + return date + } + return decoder + }() + + private static let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.sortedKeys] + return encoder + }() +} + public struct CLIProxyAPIConnectionSettings: Codable, Equatable, Sendable { public static let defaultBaseURL = "http://127.0.0.1:8317" @@ -256,7 +330,7 @@ public struct CLIProxyAPIConnectionSettings: Codable, Equatable, Sendable { let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https", let host = url.host?.lowercased(), - scheme == "https" || ["127.0.0.1", "::1", "localhost"].contains(host) + ["127.0.0.1", "::1", "localhost"].contains(host) else { return nil } return url } @@ -322,27 +396,10 @@ private actor CLIProxyAPIUsageCollectionGate { } } -private actor CLIProxyAPIUsageRetryBuffer { - private var recordsByCachePath: [String: [CLIProxyAPIUsageRecord]] = [:] - - func records(for cachePath: String) -> [CLIProxyAPIUsageRecord] { - self.recordsByCachePath[cachePath] ?? [] - } - - func store(_ records: [CLIProxyAPIUsageRecord], for cachePath: String) { - self.recordsByCachePath[cachePath] = records - } - - func clear(for cachePath: String) { - self.recordsByCachePath.removeValue(forKey: cachePath) - } -} - public enum CLIProxyAPIUsageCollector { private static let maximumBatches = 10 private static let batchSize = 100 private static let collectionGate = CLIProxyAPIUsageCollectionGate() - private static let retryBuffer = CLIProxyAPIUsageRetryBuffer() public static func collect( cacheRoot: URL? = nil, @@ -357,22 +414,27 @@ public enum CLIProxyAPIUsageCollector { static func collect( cacheRoot: URL? = nil, + pendingRoot: URL? = nil, client: CLIProxyAPIUsageQueueClient) async -> CLIProxyAPIUsageCollectionResult { await self.collectionGate.perform { - await self.collectUnserialized(cacheRoot: cacheRoot, client: client) + await self.collectUnserialized( + cacheRoot: cacheRoot, + pendingRoot: pendingRoot, + client: client) } } private static func collectUnserialized( cacheRoot: URL?, + pendingRoot: URL?, client: CLIProxyAPIUsageQueueClient) async -> CLIProxyAPIUsageCollectionResult { do { var added = 0 - let cachePath = CLIProxyAPIUsageCacheIO.cacheFileURL(cacheRoot: cacheRoot) - .standardizedFileURL.path - let pendingRecords = await self.retryBuffer.records(for: cachePath) + guard let pendingRecords = CLIProxyAPIUsagePendingIO.load(pendingRoot: pendingRoot) else { + return .failed("Could not load pending CLIProxyAPI usage telemetry.") + } if !pendingRecords.isEmpty { guard let pendingAdded = CLIProxyAPIUsageCacheIO.merge( pendingRecords, @@ -381,18 +443,25 @@ public enum CLIProxyAPIUsageCollector { return .failed("Could not save CLIProxyAPI usage telemetry.") } added += pendingAdded - await self.retryBuffer.clear(for: cachePath) + guard CLIProxyAPIUsagePendingIO.clear(pendingRoot: pendingRoot) else { + return .failed("Could not clear pending CLIProxyAPI usage telemetry.") + } } for _ in 0.. Date: Tue, 28 Jul 2026 00:30:54 -0700 Subject: [PATCH 025/183] Isolate telemetry staging tests --- Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 378b7a8032..458fe9c480 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -432,7 +432,8 @@ public enum CLIProxyAPIUsageCollector { { do { var added = 0 - guard let pendingRecords = CLIProxyAPIUsagePendingIO.load(pendingRoot: pendingRoot) else { + let effectivePendingRoot = pendingRoot ?? cacheRoot + guard let pendingRecords = CLIProxyAPIUsagePendingIO.load(pendingRoot: effectivePendingRoot) else { return .failed("Could not load pending CLIProxyAPI usage telemetry.") } if !pendingRecords.isEmpty { @@ -443,7 +444,7 @@ public enum CLIProxyAPIUsageCollector { return .failed("Could not save CLIProxyAPI usage telemetry.") } added += pendingAdded - guard CLIProxyAPIUsagePendingIO.clear(pendingRoot: pendingRoot) else { + guard CLIProxyAPIUsagePendingIO.clear(pendingRoot: effectivePendingRoot) else { return .failed("Could not clear pending CLIProxyAPI usage telemetry.") } } @@ -452,13 +453,13 @@ public enum CLIProxyAPIUsageCollector { let batch = try await client.pop(count: self.batchSize) let staged = batch.isEmpty || CLIProxyAPIUsagePendingIO.save( batch, - pendingRoot: pendingRoot) + pendingRoot: effectivePendingRoot) guard let batchAdded = CLIProxyAPIUsageCacheIO.merge(batch, cacheRoot: cacheRoot) else { return .failed("Could not save CLIProxyAPI usage telemetry.") } added += batchAdded if staged, !batch.isEmpty, - !CLIProxyAPIUsagePendingIO.clear(pendingRoot: pendingRoot) + !CLIProxyAPIUsagePendingIO.clear(pendingRoot: effectivePendingRoot) { return .failed("Could not clear pending CLIProxyAPI usage telemetry.") } From 5dcc12d623f3bcaa2aac0c089e985fb47fabfd38 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 28 Jul 2026 00:43:02 -0700 Subject: [PATCH 026/183] Keep proxy subscriptions isolated --- .../CodexBar/PreferencesSpendDashboardPane.swift | 13 ++++++++++++- Tests/CodexBarTests/ShareStatsTests.swift | 13 +++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index 4725ec3932..f2f1f23f66 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -27,6 +27,13 @@ func spendDashboardCoverageText(covered: Int, requested: Int) -> String { "\(L("Coverage")): \(codexBarLocalizedInteger(covered)) / \(codexBarLocalizedInteger(requested))" } +func spendDashboardShouldUseAmbientCodexSubscription( + rowID: String, + codexRowCount: Int) -> Bool +{ + rowID != SpendDashboardSource.codexProxySourceID && codexRowCount == 1 +} + func spendDashboardModelSourceText( providerName: String, attribution: CostUsageAttribution?) -> String @@ -361,7 +368,11 @@ struct SpendDashboardPane: View { self.store.codexAccountSnapshots.first { row.id == "codex:\($0.id)" }?.snapshot, - codexRowCount == 1 ? self.store.snapshot(for: .codex) : nil, + spendDashboardShouldUseAmbientCodexSubscription( + rowID: row.id, + codexRowCount: codexRowCount) + ? self.store.snapshot(for: .codex) + : nil, ] } else { [self.store.snapshot(for: row.provider)] diff --git a/Tests/CodexBarTests/ShareStatsTests.swift b/Tests/CodexBarTests/ShareStatsTests.swift index adb0a24651..c2efeec8ae 100644 --- a/Tests/CodexBarTests/ShareStatsTests.swift +++ b/Tests/CodexBarTests/ShareStatsTests.swift @@ -5,6 +5,19 @@ import Testing @testable import CodexBar struct ShareStatsTests { + @Test + func `proxy spend does not inherit an ambient codex subscription`() { + #expect(!spendDashboardShouldUseAmbientCodexSubscription( + rowID: SpendDashboardSource.codexProxySourceID, + codexRowCount: 1)) + #expect(spendDashboardShouldUseAmbientCodexSubscription( + rowID: "codex:managed-account", + codexRowCount: 1)) + #expect(!spendDashboardShouldUseAmbientCodexSubscription( + rowID: "codex:managed-account", + codexRowCount: 2)) + } + @Test func `builder preserves native currencies and unavailable spend`() throws { let subscriptionNames = try [ From 4e845a77d7c35981b87fe940416b8d3824caf75e Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 28 Jul 2026 00:54:38 -0700 Subject: [PATCH 027/183] Respect cost tracking opt out --- .../PreferencesSpendDashboardPane.swift | 2 + Sources/CodexBar/UsageStore+CLIProxyAPI.swift | 13 +++-- .../CLIProxyAPIUsageTelemetry.swift | 1 + .../CLIProxyAPIUsageStoreTests.swift | 51 +++++++++++++++++++ 4 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index f2f1f23f66..c732eb819f 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -313,6 +313,8 @@ struct SpendDashboardPane: View { self.cliProxyAPIManagementKey = "" self.cliProxyAPIHasSavedConfiguration = true switch await self.store.collectCLIProxyAPIUsageNow() { + case .disabled: + self.cliProxyAPIStatus = "Enable Track costs to test." case .notConfigured: self.cliProxyAPIStatus = "Configuration was not available." case let .collected(count): diff --git a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift index e4ff555642..d492251d85 100644 --- a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift +++ b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift @@ -7,9 +7,9 @@ extension UsageStore { func startCLIProxyAPIUsageCollector() { self.cliProxyAPIUsageCollectorTask?.cancel() - self.cliProxyAPIUsageCollectorTask = Task.detached(priority: .utility) { + self.cliProxyAPIUsageCollectorTask = Task.detached(priority: .utility) { [weak self] in while !Task.isCancelled { - _ = await CLIProxyAPIUsageCollector.collect() + guard await self?.collectCLIProxyAPIUsageNow() != nil else { return } do { try await Task.sleep(for: Self.cliProxyAPIUsageCollectionInterval) } catch { @@ -19,7 +19,12 @@ extension UsageStore { } } - func collectCLIProxyAPIUsageNow() async -> CLIProxyAPIUsageCollectionResult { - await CLIProxyAPIUsageCollector.collect() + func collectCLIProxyAPIUsageNow( + collector: @escaping @Sendable () async -> CLIProxyAPIUsageCollectionResult = { + await CLIProxyAPIUsageCollector.collect() + }) async -> CLIProxyAPIUsageCollectionResult + { + guard self.settings.costUsageEnabled else { return .disabled } + return await collector() } } diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 458fe9c480..676b39e03c 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -361,6 +361,7 @@ public enum CLIProxyAPIConnectionSettingsStore { } public enum CLIProxyAPIUsageCollectionResult: Equatable, Sendable { + case disabled case notConfigured case collected(Int) case failed(String) diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift new file mode 100644 index 0000000000..d5f5192265 --- /dev/null +++ b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift @@ -0,0 +1,51 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +private actor CLIProxyAPIUsageCollectionRecorder { + private(set) var callCount = 0 + + func collect() -> CLIProxyAPIUsageCollectionResult { + self.callCount += 1 + return .collected(1) + } +} + +@MainActor +struct CLIProxyAPIUsageStoreTests { + @Test + func `cost tracking opt out prevents telemetry collection`() async { + let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") + settings.costUsageEnabled = false + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cliproxy-usage-store-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let environment = [ + "HOME": root.path, + "CODEX_HOME": root.appendingPathComponent(".codex", isDirectory: true).path, + ] + let store = UsageStore( + fetcher: UsageFetcher(environment: environment), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: environment) + let recorder = CLIProxyAPIUsageCollectionRecorder() + + let disabledResult = await store.collectCLIProxyAPIUsageNow { + await recorder.collect() + } + + #expect(disabledResult == .disabled) + #expect(await recorder.callCount == 0) + + settings.costUsageEnabled = true + let enabledResult = await store.collectCLIProxyAPIUsageNow { + await recorder.collect() + } + + #expect(enabledResult == .collected(1)) + #expect(await recorder.callCount == 1) + } +} From 282e01b47a02119e5ea032faccc0547ba50d0104 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 28 Jul 2026 01:04:58 -0700 Subject: [PATCH 028/183] Recheck telemetry opt out and pricing --- Sources/CodexBar/UsageStore+CLIProxyAPI.swift | 12 ++-- .../CLIProxyAPIUsageTelemetry.swift | 8 ++- .../CostUsage/CostUsageScanner+Claude.swift | 37 ++++++++--- .../CLIProxyAPIUsageCollectorTests.swift | 61 +++++++++++++++++++ ...UsageFetcherUnknownModelPricingTests.swift | 22 +++++++ 5 files changed, 126 insertions(+), 14 deletions(-) diff --git a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift index d492251d85..ec7b1c950a 100644 --- a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift +++ b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift @@ -20,11 +20,15 @@ extension UsageStore { } func collectCLIProxyAPIUsageNow( - collector: @escaping @Sendable () async -> CLIProxyAPIUsageCollectionResult = { - await CLIProxyAPIUsageCollector.collect() - }) async -> CLIProxyAPIUsageCollectionResult + collector: (@Sendable () async -> CLIProxyAPIUsageCollectionResult)? = nil) async + -> CLIProxyAPIUsageCollectionResult { guard self.settings.costUsageEnabled else { return .disabled } - return await collector() + if let collector { + return await collector() + } + return await CLIProxyAPIUsageCollector.collect(shouldContinue: { [weak self] in + await self?.settings.costUsageEnabled == true + }) } } diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 676b39e03c..037579dbd1 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -404,24 +404,28 @@ public enum CLIProxyAPIUsageCollector { public static func collect( cacheRoot: URL? = nil, - settings: CLIProxyAPIConnectionSettings? = CLIProxyAPIConnectionSettingsStore.load()) async + settings: CLIProxyAPIConnectionSettings? = CLIProxyAPIConnectionSettingsStore.load(), + shouldContinue: @escaping @Sendable () async -> Bool = { true }) async -> CLIProxyAPIUsageCollectionResult { guard let settings, settings.isConfigured else { return .notConfigured } return await self.collect( cacheRoot: cacheRoot, + shouldContinue: shouldContinue, client: CLIProxyAPIUsageQueueClient(settings: settings)) } static func collect( cacheRoot: URL? = nil, pendingRoot: URL? = nil, + shouldContinue: @escaping @Sendable () async -> Bool = { true }, client: CLIProxyAPIUsageQueueClient) async -> CLIProxyAPIUsageCollectionResult { await self.collectionGate.perform { await self.collectUnserialized( cacheRoot: cacheRoot, pendingRoot: pendingRoot, + shouldContinue: shouldContinue, client: client) } } @@ -429,6 +433,7 @@ public enum CLIProxyAPIUsageCollector { private static func collectUnserialized( cacheRoot: URL?, pendingRoot: URL?, + shouldContinue: @escaping @Sendable () async -> Bool, client: CLIProxyAPIUsageQueueClient) async -> CLIProxyAPIUsageCollectionResult { do { @@ -451,6 +456,7 @@ public enum CLIProxyAPIUsageCollector { } for _ in 0.. Double? + { + let pricingModelUnchanged = cachedPricingModel.caseInsensitiveCompare(pricingModel) == .orderedSame + if wasPriced, cachedCostNanos == 0, pricingModelUnchanged { + return 0 + } + if let currentCost { + return currentCost + } + guard wasPriced, pricingModelUnchanged else { return nil } + return Double(cachedCostNanos) / Self.costScale + } + private static func currentClaudeRowCost( _ row: ClaudeUsageRow, pricingModel: String, diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift index 852beab625..b270103e1b 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift @@ -2,6 +2,20 @@ import Foundation import Testing @testable import CodexBarCore +private actor CLIProxyAPICollectionContinuationProbe { + private(set) var popCount = 0 + private var continuationCheckCount = 0 + + func shouldContinue() -> Bool { + self.continuationCheckCount += 1 + return self.continuationCheckCount == 1 + } + + func recordPop() { + self.popCount += 1 + } +} + struct CLIProxyAPIUsageCollectorTests { @Test func `persists a popped batch outside a failed cache for the next collection`() async throws { @@ -71,4 +85,51 @@ struct CLIProxyAPIUsageCollectorTests { #expect(!fileManager.fileExists( atPath: CLIProxyAPIUsagePendingIO.pendingFileURL(pendingRoot: pendingRoot).path)) } + + @Test + func `collector rechecks opt out before every destructive pop`() async throws { + let fileManager = FileManager.default + let cacheRoot = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-opt-out-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: cacheRoot) } + let timestamp = try #require(CostUsageDateParser.parse("2026-07-16T12:00:00Z")) + let records = (0..<100).map { index in + CLIProxyAPIUsageRecord( + timestamp: timestamp.addingTimeInterval(TimeInterval(index)), + provider: "codex", + model: "gpt-5.6-sol", + alias: "gpt-5.6-sol", + endpoint: "POST /v1/messages", + authType: "oauth", + requestID: "request-\(index)", + tokens: .init(input: 10, output: 20, total: 30)) + } + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(records) + let probe = CLIProxyAPICollectionContinuationProbe() + let client = CLIProxyAPIUsageQueueClient( + settings: .init(managementKey: "management-secret"), + dataLoader: { request in + await probe.recordPop() + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + return (data, response) + }) + + let result = await CLIProxyAPIUsageCollector.collect( + cacheRoot: cacheRoot, + shouldContinue: { + await probe.shouldContinue() + }, + client: client) + + #expect(result == .disabled) + #expect(await probe.popCount == 1) + #expect(CLIProxyAPIUsageCacheIO.load(cacheRoot: cacheRoot).count == 100) + } } diff --git a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift index e8808734bf..64cb9578bc 100644 --- a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift @@ -6,6 +6,28 @@ import Testing @testable import CodexBarCore struct CostUsageFetcherUnknownModelPricingTests { + @Test + func `resolved upstream model does not reuse a cached alias price`() { + #expect(CostUsageScanner.resolvedClaudeRowCost( + wasPriced: true, + cachedCostNanos: 1_000_000_000, + cachedPricingModel: "claude-priced-alias", + pricingModel: "unpriced-upstream", + currentCost: nil) == nil) + #expect(CostUsageScanner.resolvedClaudeRowCost( + wasPriced: true, + cachedCostNanos: 1_000_000_000, + cachedPricingModel: "claude-priced-alias", + pricingModel: "claude-priced-alias", + currentCost: nil) == 1) + #expect(CostUsageScanner.resolvedClaudeRowCost( + wasPriced: true, + cachedCostNanos: 1_000_000_000, + cachedPricingModel: "claude-priced-alias", + pricingModel: "priced-upstream", + currentCost: 2) == 2) + } + @Test func `fetcher reprices an unknown model after an on demand catalog refresh`() async throws { let fixture = try UnknownModelPricingFixture() From 2f8747b9353f7a100449ba388fb5c5c14b800aed Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 28 Jul 2026 04:57:58 -0700 Subject: [PATCH 029/183] Fix attribution sort expectation --- Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift index 288676ffe1..f0cfabfde1 100644 --- a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift +++ b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift @@ -226,7 +226,7 @@ struct CostUsageDailyReportMergeTests { summary: nil) let breakdowns = try #require(CostUsageDailyReport.merged([report]).data.first?.modelBreakdowns) - #expect(breakdowns.map(\.attribution) == [oauthAttribution, apiKeyAttribution]) + #expect(breakdowns.map(\.attribution) == [apiKeyAttribution, oauthAttribution]) } @Test From b5d942636be3253111c09973eeb3c55d8c8660db Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 28 Jul 2026 05:01:05 -0700 Subject: [PATCH 030/183] Complete attribution sort tie-breaks --- .../CodexBar/CostHistoryChartMenuView.swift | 8 +++- .../Generated/CodexParserHash.generated.swift | 2 +- .../CostUsageScanner+CacheHelpers.swift | 8 +--- .../CostHistoryChartMenuViewTests.swift | 40 +++++++++++++++++++ .../CostUsageDailyReportMergeTests.swift | 40 +++++++++++++++++++ 5 files changed, 90 insertions(+), 8 deletions(-) diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index 8bcc5a0c4c..accc650e5c 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -822,7 +822,13 @@ struct CostHistoryChartMenuView: View { return lTokens > rTokens } - return lhs.modelName > rhs.modelName + if lhs.modelName != rhs.modelName { + return lhs.modelName > rhs.modelName + } + + let lhsAttribution = lhs.attribution?.deterministicSortKey ?? "" + let rhsAttribution = rhs.attribution?.deterministicSortKey ?? "" + return lhsAttribution > rhsAttribution } } diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index ee6bc93f3d..2dbf2fa2f4 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 = "cbccd03ce19eadfd" + static let value = "2baf7a4ea7fcc74d" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift index 20ea28aedc..91073c25c0 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift @@ -1511,12 +1511,8 @@ extension CostUsageScanner { if lhs.modelName != rhs.modelName { return lhs.modelName > rhs.modelName } - let lhsAttribution = lhs.attribution.map { - "\($0.client.rawValue):\($0.route.rawValue):\($0.upstream?.provider ?? "")" - } ?? "" - let rhsAttribution = rhs.attribution.map { - "\($0.client.rawValue):\($0.route.rawValue):\($0.upstream?.provider ?? "")" - } ?? "" + let lhsAttribution = lhs.attribution?.deterministicSortKey ?? "" + let rhsAttribution = rhs.attribution?.deterministicSortKey ?? "" return lhsAttribution > rhsAttribution } } diff --git a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift index d7d4b24f00..c4dc0ee040 100644 --- a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift +++ b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift @@ -39,6 +39,46 @@ struct CostHistoryChartMenuViewTests { #expect(CostHistoryChartMenuView.detailOverflowHint(itemCount: 4) == nil) } + @Test + func `model breakdown uses complete attribution as its final ordering key`() { + let apiKeyAttribution = CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .openAI, + upstream: .init( + provider: "codex", + authType: .apiKey, + model: "gpt-5.4", + executorType: "codex"), + evidence: [.cliProxyRequestLog]) + let oauthAttribution = CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .openAI, + upstream: .init( + provider: "codex", + authType: .oauth, + model: "gpt-5.4", + executorType: "codex"), + evidence: [.cliProxyRequestLog]) + let breakdowns = [ + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.4", + costUSD: 0.1, + totalTokens: 10, + attribution: oauthAttribution), + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.4", + costUSD: 0.1, + totalTokens: 10, + attribution: apiKeyAttribution), + ] + + let sorted = CostHistoryChartMenuView.orderedBreakdownItems(breakdowns) + + #expect(sorted.map(\.attribution) == [apiKeyAttribution, oauthAttribution]) + } + @Test @MainActor func `menu hosting view publishes measured height through intrinsic size`() { diff --git a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift index f0cfabfde1..be9877c109 100644 --- a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift +++ b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift @@ -229,6 +229,46 @@ struct CostUsageDailyReportMergeTests { #expect(breakdowns.map(\.attribution) == [apiKeyAttribution, oauthAttribution]) } + @Test + func `vendored cache sorter uses complete attribution for equal model breakdowns`() { + let apiKeyAttribution = CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .openAI, + upstream: .init( + provider: "codex", + authType: .apiKey, + model: "gpt-5.4", + executorType: "codex"), + evidence: [.cliProxyRequestLog]) + let oauthAttribution = CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .openAI, + upstream: .init( + provider: "codex", + authType: .oauth, + model: "gpt-5.4", + executorType: "codex"), + evidence: [.cliProxyRequestLog]) + let breakdowns = [ + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.4", + costUSD: 0.1, + totalTokens: 10, + attribution: oauthAttribution), + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.4", + costUSD: 0.1, + totalTokens: 10, + attribution: apiKeyAttribution), + ] + + let sorted = CostUsageScanner.sortedModelBreakdowns(breakdowns) + + #expect(sorted.map(\.attribution) == [apiKeyAttribution, oauthAttribution]) + } + @Test func `attribution sort key includes every distinguishable field`() { let attributions = [ From 895de6baa27777a7a3621f360185eca8853dc88f Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 28 Jul 2026 05:05:42 -0700 Subject: [PATCH 031/183] Split chart attribution sort coverage --- .../CodexBar/CostHistoryChartMenuView.swift | 52 ++++++------ .../CostHistoryChartMenuViewTests.swift | 80 +++++++++---------- 2 files changed, 66 insertions(+), 66 deletions(-) diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index accc650e5c..8efa27dd56 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -806,32 +806,6 @@ struct CostHistoryChartMenuView: View { } } - static func orderedBreakdownItems( - _ breakdown: [CostUsageDailyReport.ModelBreakdown]) -> [CostUsageDailyReport.ModelBreakdown] - { - breakdown.sorted { lhs, rhs in - let lCost = lhs.costUSD ?? -1 - let rCost = rhs.costUSD ?? -1 - if lCost != rCost { - return lCost > rCost - } - - let lTokens = lhs.totalTokens ?? -1 - let rTokens = rhs.totalTokens ?? -1 - if lTokens != rTokens { - return lTokens > rTokens - } - - if lhs.modelName != rhs.modelName { - return lhs.modelName > rhs.modelName - } - - let lhsAttribution = lhs.attribution?.deterministicSortKey ?? "" - let rhsAttribution = rhs.attribution?.deterministicSortKey ?? "" - return lhsAttribution > rhsAttribution - } - } - static func detailViewportRowCount(itemCount: Int) -> Int { min(max(itemCount, 0), self.maxVisibleDetailLines) } @@ -904,6 +878,32 @@ struct CostHistoryChartMenuView: View { } extension CostHistoryChartMenuView { + static func orderedBreakdownItems( + _ breakdown: [CostUsageDailyReport.ModelBreakdown]) -> [CostUsageDailyReport.ModelBreakdown] + { + breakdown.sorted { lhs, rhs in + let lCost = lhs.costUSD ?? -1 + let rCost = rhs.costUSD ?? -1 + if lCost != rCost { + return lCost > rCost + } + + let lTokens = lhs.totalTokens ?? -1 + let rTokens = rhs.totalTokens ?? -1 + if lTokens != rTokens { + return lTokens > rTokens + } + + if lhs.modelName != rhs.modelName { + return lhs.modelName > rhs.modelName + } + + let lhsAttribution = lhs.attribution?.deterministicSortKey ?? "" + let rhsAttribution = rhs.attribution?.deterministicSortKey ?? "" + return lhsAttribution > rhsAttribution + } + } + struct RenderFingerprint: Equatable { let currencyCode: String let historyDays: Int diff --git a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift index c4dc0ee040..662abd2fd4 100644 --- a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift +++ b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift @@ -39,46 +39,6 @@ struct CostHistoryChartMenuViewTests { #expect(CostHistoryChartMenuView.detailOverflowHint(itemCount: 4) == nil) } - @Test - func `model breakdown uses complete attribution as its final ordering key`() { - let apiKeyAttribution = CostUsageAttribution( - client: .claudeCode, - route: .cliProxyAPI, - modelProvider: .openAI, - upstream: .init( - provider: "codex", - authType: .apiKey, - model: "gpt-5.4", - executorType: "codex"), - evidence: [.cliProxyRequestLog]) - let oauthAttribution = CostUsageAttribution( - client: .claudeCode, - route: .cliProxyAPI, - modelProvider: .openAI, - upstream: .init( - provider: "codex", - authType: .oauth, - model: "gpt-5.4", - executorType: "codex"), - evidence: [.cliProxyRequestLog]) - let breakdowns = [ - CostUsageDailyReport.ModelBreakdown( - modelName: "gpt-5.4", - costUSD: 0.1, - totalTokens: 10, - attribution: oauthAttribution), - CostUsageDailyReport.ModelBreakdown( - modelName: "gpt-5.4", - costUSD: 0.1, - totalTokens: 10, - attribution: apiKeyAttribution), - ] - - let sorted = CostHistoryChartMenuView.orderedBreakdownItems(breakdowns) - - #expect(sorted.map(\.attribution) == [apiKeyAttribution, oauthAttribution]) - } - @Test @MainActor func `menu hosting view publishes measured height through intrinsic size`() { @@ -905,6 +865,46 @@ struct CostHistoryChartMenuViewTests { } extension CostHistoryChartMenuViewTests { + @Test + func `model breakdown uses complete attribution as its final ordering key`() { + let apiKeyAttribution = CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .openAI, + upstream: .init( + provider: "codex", + authType: .apiKey, + model: "gpt-5.4", + executorType: "codex"), + evidence: [.cliProxyRequestLog]) + let oauthAttribution = CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .openAI, + upstream: .init( + provider: "codex", + authType: .oauth, + model: "gpt-5.4", + executorType: "codex"), + evidence: [.cliProxyRequestLog]) + let breakdowns = [ + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.4", + costUSD: 0.1, + totalTokens: 10, + attribution: oauthAttribution), + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.4", + costUSD: 0.1, + totalTokens: 10, + attribution: apiKeyAttribution), + ] + + let sorted = CostHistoryChartMenuView.orderedBreakdownItems(breakdowns) + + #expect(sorted.map(\.attribution) == [apiKeyAttribution, oauthAttribution]) + } + @Test func `session labels distinguish concurrent uuid v7 identifiers`() { let first = CostHistoryChartMenuView.shortSessionID("019f6d91-970b-7e13-b08e-000000000001") From 01ffbe25a160e213aca24c049c9c016fb823676f Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 28 Jul 2026 10:43:50 -0700 Subject: [PATCH 032/183] Remove feature changelog entry --- CHANGELOG.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b5547f6dd..6d779522f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,6 @@ ### Fixed - Ollama: reuse validated browser sessions across refreshes, and skip inaccessible Safari cookies during automatic fallback while preserving explicit Safari permission guidance. -- Usage & Spend: keep CLIProxyAPI-routed Claude Code sessions in the correct upstream total using request logs - and active provider credentials, with sanitized per-request telemetry when available, instead of splitting - unchanged usage into separate proxy and unknown-route rows. ## 0.45.2 — 2026-07-19 From 09262e9f7482126e2511e8a12b531ad9fc21d3b6 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 28 Jul 2026 10:51:56 -0700 Subject: [PATCH 033/183] Stop proxy collection on removal --- .../PreferencesSpendDashboardPane.swift | 6 ++- Sources/CodexBar/UsageStore+CLIProxyAPI.swift | 15 +++++- .../CLIProxyAPIUsageStoreTests.swift | 46 +++++++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index c732eb819f..b5ee3c4764 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -305,7 +305,9 @@ struct SpendDashboardPane: View { self.cliProxyAPIStatus = "Enter a loopback URL and management key." return } + self.store.stopCLIProxyAPIUsageCollector() guard CLIProxyAPIConnectionSettingsStore.save(configuration) else { + self.store.startCLIProxyAPIUsageCollector() self.cliProxyAPIStatus = "Could not save the management key." return } @@ -325,10 +327,12 @@ struct SpendDashboardPane: View { case let .failed(message): self.cliProxyAPIStatus = "Saved, but test failed: \(message)" } + self.store.startCLIProxyAPIUsageCollector() } private func removeCLIProxyAPIConfiguration() { - guard CLIProxyAPIConnectionSettingsStore.clear() else { + guard self.store.removeCLIProxyAPIConfiguration() else { + self.store.startCLIProxyAPIUsageCollector() self.cliProxyAPIStatus = "Could not remove the saved configuration." return } diff --git a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift index ec7b1c950a..6b84388844 100644 --- a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift +++ b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift @@ -6,7 +6,7 @@ extension UsageStore { private static let cliProxyAPIUsageCollectionInterval: Duration = .seconds(30) func startCLIProxyAPIUsageCollector() { - self.cliProxyAPIUsageCollectorTask?.cancel() + self.stopCLIProxyAPIUsageCollector() self.cliProxyAPIUsageCollectorTask = Task.detached(priority: .utility) { [weak self] in while !Task.isCancelled { guard await self?.collectCLIProxyAPIUsageNow() != nil else { return } @@ -19,6 +19,19 @@ extension UsageStore { } } + func stopCLIProxyAPIUsageCollector() { + self.cliProxyAPIUsageCollectorTask?.cancel() + self.cliProxyAPIUsageCollectorTask = nil + } + + @discardableResult + func removeCLIProxyAPIConfiguration( + clear: () -> Bool = { CLIProxyAPIConnectionSettingsStore.clear() }) -> Bool + { + self.stopCLIProxyAPIUsageCollector() + return clear() + } + func collectCLIProxyAPIUsageNow( collector: (@Sendable () async -> CLIProxyAPIUsageCollectionResult)? = nil) async -> CLIProxyAPIUsageCollectionResult diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift index d5f5192265..4437ce8217 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift @@ -12,6 +12,14 @@ private actor CLIProxyAPIUsageCollectionRecorder { } } +private actor CLIProxyAPIUsageCollectorCancellationRecorder { + private(set) var wasCancelled = false + + func recordCancellation() { + self.wasCancelled = true + } +} + @MainActor struct CLIProxyAPIUsageStoreTests { @Test @@ -48,4 +56,42 @@ struct CLIProxyAPIUsageStoreTests { #expect(enabledResult == .collected(1)) #expect(await recorder.callCount == 1) } + + @Test + func `removing the integration cancels and clears the active telemetry task`() async { + let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cliproxy-usage-store-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let environment = [ + "HOME": root.path, + "CODEX_HOME": root.appendingPathComponent(".codex", isDirectory: true).path, + ] + let store = UsageStore( + fetcher: UsageFetcher(environment: environment), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: environment) + let recorder = CLIProxyAPIUsageCollectorCancellationRecorder() + let task = Task { + while !Task.isCancelled { + await Task.yield() + } + await recorder.recordCancellation() + } + store.cliProxyAPIUsageCollectorTask = task + var didClearConfiguration = false + + let removed = store.removeCLIProxyAPIConfiguration { + didClearConfiguration = true + return true + } + await task.value + + #expect(removed) + #expect(didClearConfiguration) + #expect(store.cliProxyAPIUsageCollectorTask == nil) + #expect(await recorder.wasCancelled) + } } From 95028de6896deea18c5da3dd068c93ddd75819df Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 28 Jul 2026 11:01:33 -0700 Subject: [PATCH 034/183] Persist proxy telemetry durably --- .../CLIProxyAPIUsageTelemetry.swift | 85 +++++++++++++++++-- .../CLIProxyAPIUsageCacheTests.swift | 60 +++++++++++++ 2 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 037579dbd1..c748c3ef0d 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -133,9 +133,23 @@ enum CLIProxyAPIUsageCacheIO { static func load( cacheRoot: URL? = nil, now: Date = Date()) -> [CLIProxyAPIUsageRecord] + { + let legacyCacheRoot = cacheRoot == nil ? self.defaultLegacyCacheRoot() : nil + return self.load( + cacheRoot: cacheRoot, + legacyCacheRoot: legacyCacheRoot, + now: now) + } + + static func load( + cacheRoot: URL?, + legacyCacheRoot: URL?, + now: Date = Date()) -> [CLIProxyAPIUsageRecord] { let cutoff = now.addingTimeInterval(-self.maximumRecordAge) - return self.loadCache(cacheRoot: cacheRoot).records.filter { $0.timestamp >= cutoff } + return self.loadCache( + cacheRoot: cacheRoot, + legacyCacheRoot: legacyCacheRoot).records.filter { $0.timestamp >= cutoff } } @discardableResult @@ -143,9 +157,26 @@ enum CLIProxyAPIUsageCacheIO { _ records: [CLIProxyAPIUsageRecord], cacheRoot: URL? = nil, now: Date = Date()) -> Int? + { + let legacyCacheRoot = cacheRoot == nil ? self.defaultLegacyCacheRoot() : nil + return self.merge( + records, + cacheRoot: cacheRoot, + legacyCacheRoot: legacyCacheRoot, + now: now) + } + + @discardableResult + static func merge( + _ records: [CLIProxyAPIUsageRecord], + cacheRoot: URL?, + legacyCacheRoot: URL?, + now: Date = Date()) -> Int? { let cutoff = now.addingTimeInterval(-self.maximumRecordAge) - let existingCache = self.loadCache(cacheRoot: cacheRoot) + let existingCache = self.loadCache( + cacheRoot: cacheRoot, + legacyCacheRoot: legacyCacheRoot) var byKey: [String: CLIProxyAPIUsageRecord] = [:] for record in existingCache.records where record.timestamp >= cutoff { byKey[self.recordKey(record)] = record @@ -163,13 +194,22 @@ enum CLIProxyAPIUsageCacheIO { } static func cacheFileURL(cacheRoot: URL? = nil) -> URL { - let root = cacheRoot ?? FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + let root = cacheRoot ?? FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask).first! .appendingPathComponent("CodexBar", isDirectory: true) return root .appendingPathComponent("cost-usage", isDirectory: true) .appendingPathComponent("cliproxyapi-usage-v1.json", isDirectory: false) } + static func legacyCacheFileURL(cacheRoot: URL? = nil) -> URL { + let root = cacheRoot ?? self.defaultLegacyCacheRoot() + return root + .appendingPathComponent("cost-usage", isDirectory: true) + .appendingPathComponent("cliproxyapi-usage-v1.json", isDirectory: false) + } + private static let decoder: JSONDecoder = { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .custom { decoder in @@ -213,14 +253,47 @@ enum CLIProxyAPIUsageCacheIO { ].joined(separator: ":") } - private static func loadCache(cacheRoot: URL?) -> Cache { - guard let data = try? Data(contentsOf: self.cacheFileURL(cacheRoot: cacheRoot)), + private static func defaultLegacyCacheRoot() -> URL { + FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + .appendingPathComponent("CodexBar", isDirectory: true) + } + + private static func loadCache(cacheRoot: URL?, legacyCacheRoot: URL?) -> Cache { + let durableURL = self.cacheFileURL(cacheRoot: cacheRoot) + let durableCache = self.decodeCache(at: durableURL) + guard let legacyCacheRoot else { return durableCache ?? Cache() } + + let legacyURL = self.legacyCacheFileURL(cacheRoot: legacyCacheRoot) + guard legacyURL.standardizedFileURL != durableURL.standardizedFileURL, + let legacyCache = self.decodeCache(at: legacyURL) + else { return durableCache ?? Cache() } + + let migratedCache = self.mergedCaches(legacy: legacyCache, durable: durableCache) + if self.save(migratedCache, cacheRoot: cacheRoot) { + try? FileManager.default.removeItem(at: legacyURL) + } + return migratedCache + } + + private static func decodeCache(at url: URL) -> Cache? { + guard let data = try? Data(contentsOf: url), let cache = try? self.decoder.decode(Cache.self, from: data), cache.version == 1 - else { return Cache() } + else { return nil } return cache } + private static func mergedCaches(legacy: Cache, durable: Cache?) -> Cache { + var byKey: [String: CLIProxyAPIUsageRecord] = [:] + for record in legacy.records { + byKey[self.recordKey(record)] = record + } + for record in durable?.records ?? [] { + byKey[self.recordKey(record)] = record + } + return Cache(records: byKey.values.sorted { $0.timestamp < $1.timestamp }) + } + private static func save(_ cache: Cache, cacheRoot: URL?) -> Bool { let url = self.cacheFileURL(cacheRoot: cacheRoot) let directory = url.deletingLastPathComponent() diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift new file mode 100644 index 0000000000..dd1d1e999d --- /dev/null +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -0,0 +1,60 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CLIProxyAPIUsageCacheTests { + @Test + func `default telemetry storage is durable application support`() throws { + let fileManager = FileManager.default + let durableURL = CLIProxyAPIUsageCacheIO.cacheFileURL() + let legacyURL = CLIProxyAPIUsageCacheIO.legacyCacheFileURL() + let durableRoot = try #require(fileManager.urls( + for: .applicationSupportDirectory, + in: .userDomainMask).first) + .appendingPathComponent("CodexBar", isDirectory: true) + let legacyRoot = try #require(fileManager.urls( + for: .cachesDirectory, + in: .userDomainMask).first) + .appendingPathComponent("CodexBar", isDirectory: true) + + #expect(durableURL.path.hasPrefix(durableRoot.path + "/")) + #expect(legacyURL.path.hasPrefix(legacyRoot.path + "/")) + #expect(durableURL != legacyURL) + } + + @Test + func `legacy purgeable telemetry migrates into durable storage`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-usage-migration-\(UUID().uuidString)", isDirectory: true) + let durableRoot = root.appendingPathComponent("application-support", isDirectory: true) + let legacyRoot = root.appendingPathComponent("caches", isDirectory: true) + defer { try? fileManager.removeItem(at: root) } + let timestamp = try #require(CostUsageDateParser.parse("2026-07-16T12:00:00Z")) + let record = CLIProxyAPIUsageRecord( + timestamp: timestamp, + provider: "codex", + model: "gpt-5.4", + alias: "gpt-5.4", + endpoint: "POST /v1/messages", + authType: "oauth", + requestID: "request-1", + tokens: .init(input: 10, output: 20, total: 30)) + #expect(CLIProxyAPIUsageCacheIO.merge( + [record], + cacheRoot: legacyRoot, + now: timestamp) == 1) + let legacyURL = CLIProxyAPIUsageCacheIO.cacheFileURL(cacheRoot: legacyRoot) + #expect(fileManager.fileExists(atPath: legacyURL.path)) + + let migrated = CLIProxyAPIUsageCacheIO.load( + cacheRoot: durableRoot, + legacyCacheRoot: legacyRoot, + now: timestamp) + + #expect(migrated.map(\.requestID) == ["request-1"]) + #expect(fileManager.fileExists( + atPath: CLIProxyAPIUsageCacheIO.cacheFileURL(cacheRoot: durableRoot).path)) + #expect(!fileManager.fileExists(atPath: legacyURL.path)) + } +} From f472866975ae9da5e512fceecff57738e5b4c02f Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 28 Jul 2026 13:28:10 -0700 Subject: [PATCH 035/183] Serialize proxy telemetry migration --- .../CLIProxyAPIUsageTelemetry.swift | 51 +++++++----- .../CLIProxyAPIUsageCacheTests.swift | 79 +++++++++++++++++++ 2 files changed, 109 insertions(+), 21 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index c748c3ef0d..0ad25d93af 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -128,8 +128,13 @@ enum CLIProxyAPIUsageCacheIO { var records: [CLIProxyAPIUsageRecord] = [] } + private static let cacheLock = NSLock() private static let maximumRecordAge: TimeInterval = 366 * 24 * 60 * 60 + static func withExclusiveAccess(_ body: () throws -> T) rethrows -> T { + try self.cacheLock.withLock(body) + } + static func load( cacheRoot: URL? = nil, now: Date = Date()) -> [CLIProxyAPIUsageRecord] @@ -146,10 +151,12 @@ enum CLIProxyAPIUsageCacheIO { legacyCacheRoot: URL?, now: Date = Date()) -> [CLIProxyAPIUsageRecord] { - let cutoff = now.addingTimeInterval(-self.maximumRecordAge) - return self.loadCache( - cacheRoot: cacheRoot, - legacyCacheRoot: legacyCacheRoot).records.filter { $0.timestamp >= cutoff } + self.withExclusiveAccess { + let cutoff = now.addingTimeInterval(-self.maximumRecordAge) + return self.loadCache( + cacheRoot: cacheRoot, + legacyCacheRoot: legacyCacheRoot).records.filter { $0.timestamp >= cutoff } + } } @discardableResult @@ -173,24 +180,26 @@ enum CLIProxyAPIUsageCacheIO { legacyCacheRoot: URL?, now: Date = Date()) -> Int? { - let cutoff = now.addingTimeInterval(-self.maximumRecordAge) - let existingCache = self.loadCache( - cacheRoot: cacheRoot, - legacyCacheRoot: legacyCacheRoot) - var byKey: [String: CLIProxyAPIUsageRecord] = [:] - for record in existingCache.records where record.timestamp >= cutoff { - byKey[self.recordKey(record)] = record - } - let priorCount = byKey.count - for record in records where record.timestamp >= cutoff { - byKey[self.recordKey(record)] = record - } - let cache = Cache(records: byKey.values.sorted { $0.timestamp < $1.timestamp }) - if cache == existingCache { - return 0 + self.withExclusiveAccess { + let cutoff = now.addingTimeInterval(-self.maximumRecordAge) + let existingCache = self.loadCache( + cacheRoot: cacheRoot, + legacyCacheRoot: legacyCacheRoot) + var byKey: [String: CLIProxyAPIUsageRecord] = [:] + for record in existingCache.records where record.timestamp >= cutoff { + byKey[self.recordKey(record)] = record + } + let priorCount = byKey.count + for record in records where record.timestamp >= cutoff { + byKey[self.recordKey(record)] = record + } + let cache = Cache(records: byKey.values.sorted { $0.timestamp < $1.timestamp }) + if cache == existingCache { + return 0 + } + guard self.save(cache, cacheRoot: cacheRoot) else { return nil } + return max(0, byKey.count - priorCount) } - guard self.save(cache, cacheRoot: cacheRoot) else { return nil } - return max(0, byKey.count - priorCount) } static func cacheFileURL(cacheRoot: URL? = nil) -> URL { diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index dd1d1e999d..988b7895e8 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -57,4 +57,83 @@ struct CLIProxyAPIUsageCacheTests { atPath: CLIProxyAPIUsageCacheIO.cacheFileURL(cacheRoot: durableRoot).path)) #expect(!fileManager.fileExists(atPath: legacyURL.path)) } + + @Test + func `migration and collection share exclusive cache access`() async throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-usage-lock-\(UUID().uuidString)", isDirectory: true) + let durableRoot = root.appendingPathComponent("application-support", isDirectory: true) + let legacyRoot = root.appendingPathComponent("caches", isDirectory: true) + defer { try? fileManager.removeItem(at: root) } + let timestamp = try #require(CostUsageDateParser.parse("2026-07-16T12:00:00Z")) + let legacyRecord = Self.record(id: "legacy", timestamp: timestamp) + let collectedRecord = Self.record( + id: "collected", + timestamp: timestamp.addingTimeInterval(1)) + #expect(CLIProxyAPIUsageCacheIO.merge( + [legacyRecord], + cacheRoot: legacyRoot, + now: timestamp) == 1) + + let lockAcquired = DispatchSemaphore(value: 0) + let releaseLock = DispatchSemaphore(value: 0) + let loadStarted = DispatchSemaphore(value: 0) + let loadFinished = DispatchSemaphore(value: 0) + let mergeStarted = DispatchSemaphore(value: 0) + let mergeFinished = DispatchSemaphore(value: 0) + let lockHolder = Task.detached { + CLIProxyAPIUsageCacheIO.withExclusiveAccess { + lockAcquired.signal() + releaseLock.wait() + } + } + #expect(lockAcquired.wait(timeout: .now() + 1) == .success) + + let loadTask = Task.detached { + loadStarted.signal() + let records = CLIProxyAPIUsageCacheIO.load( + cacheRoot: durableRoot, + legacyCacheRoot: legacyRoot, + now: timestamp) + loadFinished.signal() + return records + } + let mergeTask = Task.detached { + mergeStarted.signal() + let result = CLIProxyAPIUsageCacheIO.merge( + [collectedRecord], + cacheRoot: durableRoot, + legacyCacheRoot: legacyRoot, + now: timestamp) + mergeFinished.signal() + return result + } + #expect(loadStarted.wait(timeout: .now() + 1) == .success) + #expect(mergeStarted.wait(timeout: .now() + 1) == .success) + #expect(loadFinished.wait(timeout: .now() + .milliseconds(50)) == .timedOut) + #expect(mergeFinished.wait(timeout: .now() + .milliseconds(50)) == .timedOut) + + releaseLock.signal() + await lockHolder.value + _ = await loadTask.value + #expect(await mergeTask.value == 1) + let finalRecords = CLIProxyAPIUsageCacheIO.load( + cacheRoot: durableRoot, + legacyCacheRoot: legacyRoot, + now: timestamp) + #expect(Set(finalRecords.map(\.requestID)) == ["legacy", "collected"]) + } + + private static func record(id: String, timestamp: Date) -> CLIProxyAPIUsageRecord { + CLIProxyAPIUsageRecord( + timestamp: timestamp, + provider: "codex", + model: "gpt-5.4", + alias: "gpt-5.4", + endpoint: "POST /v1/messages", + authType: "oauth", + requestID: id, + tokens: .init(input: 10, output: 20, total: 30)) + } } From ab2f18370a96df11be9fb7a2dedd18a6d2bfebfa Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 28 Jul 2026 13:28:16 -0700 Subject: [PATCH 036/183] Stabilize project attribution ordering --- Sources/CodexBarCore/CostUsageFetcher.swift | 9 +++- .../CostUsageDailyReportMergeTests.swift | 47 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 3717b1d531..888b4a5df3 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -1138,7 +1138,7 @@ extension CostUsageFetcher { let attribution: CostUsageAttribution? } - private static func projectModelBreakdowns( + static func projectModelBreakdowns( from entries: [CostUsageDailyReport.Entry]) -> [CostUsageDailyReport.ModelBreakdown]? { var accumulators: [ProjectBreakdownKey: ProjectBreakdownAccumulator] = [:] @@ -1169,7 +1169,12 @@ extension CostUsageFetcher { if lhsTokens != rhsTokens { return lhsTokens > rhsTokens } - return lhs.modelName > rhs.modelName + if lhs.modelName != rhs.modelName { + return lhs.modelName > rhs.modelName + } + let lhsAttribution = lhs.attribution?.deterministicSortKey ?? "" + let rhsAttribution = rhs.attribution?.deterministicSortKey ?? "" + return lhsAttribution > rhsAttribution } } } diff --git a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift index be9877c109..358e0454b6 100644 --- a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift +++ b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift @@ -269,6 +269,53 @@ struct CostUsageDailyReportMergeTests { #expect(sorted.map(\.attribution) == [apiKeyAttribution, oauthAttribution]) } + @Test + func `project breakdown sorter uses complete attribution for equal model breakdowns`() throws { + let apiKeyAttribution = CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .openAI, + upstream: .init( + provider: "codex", + authType: .apiKey, + model: "gpt-5.4", + executorType: "codex"), + evidence: [.cliProxyRequestLog]) + let oauthAttribution = CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .openAI, + upstream: .init( + provider: "codex", + authType: .oauth, + model: "gpt-5.4", + executorType: "codex"), + evidence: [.cliProxyRequestLog]) + let entry = CostUsageDailyReport.Entry( + date: "2026-07-16", + inputTokens: 20, + outputTokens: 0, + totalTokens: 20, + costUSD: 0.2, + modelsUsed: ["gpt-5.4"], + modelBreakdowns: [ + .init( + modelName: "gpt-5.4", + costUSD: 0.1, + totalTokens: 10, + attribution: oauthAttribution), + .init( + modelName: "gpt-5.4", + costUSD: 0.1, + totalTokens: 10, + attribution: apiKeyAttribution), + ]) + + let sorted = try #require(CostUsageFetcher.projectModelBreakdowns(from: [entry])) + + #expect(sorted.map(\.attribution) == [apiKeyAttribution, oauthAttribution]) + } + @Test func `attribution sort key includes every distinguishable field`() { let attributions = [ From 1d32f3176dec4cf6c8b53fbc5780c9c0fba3520a Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 28 Jul 2026 13:36:16 -0700 Subject: [PATCH 037/183] Skip empty proxy history scans --- Sources/CodexBarCore/CostUsageFetcher.swift | 39 ++++++++++++++++++- .../CodexBarTests/CostUsageFetcherTests.swift | 18 +++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 888b4a5df3..9ada185339 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -942,7 +942,9 @@ extension CostUsageFetcher { modelsDevCacheRoot: options.cacheRoot, sessionRoots: roots) - guard includeClaudeProxy else { + guard includeClaudeProxy, + self.hasCodexProxyEvidence(options: options) + else { return CodexSupplementalScan( projects: projects, sessions: sessions, @@ -963,6 +965,41 @@ extension CostUsageFetcher { claudeProxyDaily: proxyDaily.data.isEmpty ? nil : proxyDaily) } + static func hasCodexProxyEvidence( + options: CostUsageScanner.Options, + fileManager: FileManager = .default) -> Bool + { + if CLIProxyAPIUsageCacheIO.load(cacheRoot: options.cacheRoot).contains(where: { + $0.provider.caseInsensitiveCompare("codex") == .orderedSame + }) { + return true + } + + let cachedClaude = CostUsageCacheIO.load(provider: .claude, cacheRoot: options.cacheRoot) + if cachedClaude.files.values.contains(where: { usage in + usage.claudeRows?.contains { + $0.attribution?.route == .cliProxyAPI + && $0.attribution?.upstream?.isCodex == true + } == true + }) { + return true + } + + guard let home = options.cliProxyAPIHome else { return false } + let logDirectory = home.appendingPathComponent("logs", isDirectory: true) + guard let urls = try? fileManager.contentsOfDirectory( + at: logDirectory, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles]) + else { return false } + return urls.contains { url in + guard url.pathExtension.caseInsensitiveCompare("log") == .orderedSame, + let values = try? url.resourceValues(forKeys: [.isRegularFileKey]) + else { return false } + return values.isRegularFile == true + } + } + private static func finalizeCodexSupplementalScan( projects: [CostUsageProjectBreakdown], sessions: [CostUsageSessionBreakdown], diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index ca9b80903c..fceaff1fad 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -932,6 +932,24 @@ extension CostUsageFetcherTests { #expect(snapshot.projects.allSatisfy { $0.sources.map(\.name) == [$0.name] }) } + @Test + func `codex supplemental scan requires proxy evidence`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let cliProxyHome = env.root.appendingPathComponent("cli-proxy-api", isDirectory: true) + let options = CostUsageScanner.Options( + cacheRoot: env.cacheRoot, + cliProxyAPIHome: cliProxyHome) + + #expect(!CostUsageFetcher.hasCodexProxyEvidence(options: options)) + + let logs = cliProxyHome.appendingPathComponent("logs", isDirectory: true) + try FileManager.default.createDirectory(at: logs, withIntermediateDirectories: true) + try Data().write(to: logs.appendingPathComponent("request.log")) + + #expect(CostUsageFetcher.hasCodexProxyEvidence(options: options)) + } + @Test func `openai model without proxy evidence stays out of codex totals`() async throws { let env = try CostUsageTestEnvironment() From 6731ef06017341385283df4d208a9d051d9e9c7b Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 28 Jul 2026 13:36:26 -0700 Subject: [PATCH 038/183] Exclude proxy usage from subscriptions --- .../PreferencesSpendDashboardPane.swift | 22 ++++++++++--- Tests/CodexBarTests/ShareStatsTests.swift | 33 +++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index b5ee3c4764..bc05509c48 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -34,6 +34,20 @@ func spendDashboardShouldUseAmbientCodexSubscription( rowID != SpendDashboardSource.codexProxySourceID && codexRowCount == 1 } +func spendDashboardCodexAccountRowCount( + _ rows: [SpendDashboardModel.ProviderRow]) -> Int +{ + rows.count { + $0.provider == .codex && $0.id != SpendDashboardSource.codexProxySourceID + } +} + +func spendDashboardSubscriptionCount( + _ rows: [SpendDashboardModel.ProviderRow]) -> Int +{ + rows.count { $0.id != SpendDashboardSource.codexProxySourceID } +} + func spendDashboardModelSourceText( providerName: String, attribution: CostUsageAttribution?) -> String @@ -362,9 +376,8 @@ struct SpendDashboardPane: View { private var subscriptionNames: [String: ShareStatsSubscriptionName] { var names: [String: ShareStatsSubscriptionName] = [:] - let codexRowCount = self.controller.model.groups - .flatMap(\.providers) - .count { $0.provider == .codex } + let codexRowCount = spendDashboardCodexAccountRowCount( + self.controller.model.groups.flatMap(\.providers)) for group in self.controller.model.groups { for row in group.providers { let snapshots: [UsageSnapshot?] = if row.provider == .codex, @@ -435,7 +448,8 @@ private struct SpendCurrencySection: View { value: self.group.totalTokens.map(UsageFormatter.tokenCountString) ?? "—") SpendSummaryValue( title: L("Subscriptions"), - value: codexBarLocalizedInteger(self.group.providers.count)) + value: codexBarLocalizedInteger( + spendDashboardSubscriptionCount(self.group.providers))) Spacer() } } diff --git a/Tests/CodexBarTests/ShareStatsTests.swift b/Tests/CodexBarTests/ShareStatsTests.swift index c2efeec8ae..91dc8c24e7 100644 --- a/Tests/CodexBarTests/ShareStatsTests.swift +++ b/Tests/CodexBarTests/ShareStatsTests.swift @@ -18,6 +18,39 @@ struct ShareStatsTests { codexRowCount: 2)) } + @Test + func `proxy spend is not counted as an account or subscription`() { + let rows = [ + SpendDashboardModel.ProviderRow( + id: "codex:managed-account", + rank: 1, + provider: .codex, + displayName: "Codex", + totalTokens: 100, + totalCost: 1, + coveredDayCount: 1), + SpendDashboardModel.ProviderRow( + id: SpendDashboardSource.codexProxySourceID, + rank: 2, + provider: .codex, + displayName: "Codex · CLIProxyAPI", + totalTokens: 100, + totalCost: 1, + coveredDayCount: 1), + SpendDashboardModel.ProviderRow( + id: "cursor", + rank: 3, + provider: .cursor, + displayName: "Cursor", + totalTokens: 100, + totalCost: 1, + coveredDayCount: 1), + ] + + #expect(spendDashboardCodexAccountRowCount(rows) == 1) + #expect(spendDashboardSubscriptionCount(rows) == 2) + } + @Test func `builder preserves native currencies and unavailable spend`() throws { let subscriptionNames = try [ From 7461d2ebefd498f3be164e445c53fad3aa183cb7 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 28 Jul 2026 13:50:53 -0700 Subject: [PATCH 039/183] Preserve proxy telemetry timestamps --- .../CLIProxyAPIUsageTelemetry.swift | 11 +++++++- .../CLIProxyAPIUsageCacheTests.swift | 28 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 0ad25d93af..26a35e16ae 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -234,9 +234,18 @@ enum CLIProxyAPIUsageCacheIO { return decoder }() + private static let fractionalISO8601Formatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + private static let encoder: JSONEncoder = { let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 + encoder.dateEncodingStrategy = .custom { date, encoder in + var container = encoder.singleValueContainer() + try container.encode(CLIProxyAPIUsageCacheIO.fractionalISO8601Formatter.string(from: date)) + } encoder.outputFormatting = [.sortedKeys] return encoder }() diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index 988b7895e8..76c76f01d0 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -125,6 +125,34 @@ struct CLIProxyAPIUsageCacheTests { #expect(Set(finalRecords.map(\.requestID)) == ["legacy", "collected"]) } + @Test + func `fallback record identity survives cache round trips within one second`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-usage-fractional-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: root) } + let second = try #require(CostUsageDateParser.parse("2026-07-16T12:00:00Z")) + let records = [ + Self.record(id: "", timestamp: second.addingTimeInterval(0.1)), + Self.record(id: "", timestamp: second.addingTimeInterval(0.9)), + ] + + #expect(CLIProxyAPIUsageCacheIO.merge( + records, + cacheRoot: root, + now: second) == 2) + #expect(CLIProxyAPIUsageCacheIO.merge( + [], + cacheRoot: root, + now: second) == 0) + + let roundTripped = CLIProxyAPIUsageCacheIO.load( + cacheRoot: root, + now: second) + #expect(roundTripped.count == 2) + #expect(roundTripped.map(\.timestamp) == records.map(\.timestamp)) + } + private static func record(id: String, timestamp: Date) -> CLIProxyAPIUsageRecord { CLIProxyAPIUsageRecord( timestamp: timestamp, From 37ddfd8f5ca3893fefd670e7f3301b1ca1c3a3f5 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 28 Jul 2026 13:55:52 -0700 Subject: [PATCH 040/183] Avoid shared timestamp formatter --- Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 26a35e16ae..420a17d876 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -234,17 +234,13 @@ enum CLIProxyAPIUsageCacheIO { return decoder }() - private static let fractionalISO8601Formatter: ISO8601DateFormatter = { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter - }() - private static let encoder: JSONEncoder = { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .custom { date, encoder in + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] var container = encoder.singleValueContainer() - try container.encode(CLIProxyAPIUsageCacheIO.fractionalISO8601Formatter.string(from: date)) + try container.encode(formatter.string(from: date)) } encoder.outputFormatting = [.sortedKeys] return encoder From b37f346b76cf680e8aa57032793df68499f9eee7 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 28 Jul 2026 14:05:48 -0700 Subject: [PATCH 041/183] Preserve valid proxy queue records --- .../CLIProxyAPIUsageTelemetry.swift | 42 +++++++++++++++---- .../CLIProxyAPIUsageCollectorTests.swift | 42 +++++++++++++++++++ 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 420a17d876..b6a28abc50 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -544,20 +544,23 @@ public enum CLIProxyAPIUsageCollector { for _ in 0.. (Data, URLResponse) + struct PoppedBatch: Sendable { + let records: [CLIProxyAPIUsageRecord] + let receivedCount: Int + } + + private struct LossyRecord: Decodable { + let value: CLIProxyAPIUsageRecord? + + init(from decoder: Decoder) { + self.value = try? CLIProxyAPIUsageRecord(from: decoder) + } + } + + private static let log = CodexBarLog.logger(LogCategories.tokenCost) + enum ClientError: LocalizedError { case invalidBaseURL case invalidResponse @@ -596,7 +614,7 @@ struct CLIProxyAPIUsageQueueClient: Sendable { self.dataLoader = dataLoader } - func pop(count: Int) async throws -> [CLIProxyAPIUsageRecord] { + func pop(count: Int) async throws -> PoppedBatch { guard let baseURL = self.settings.resolvedBaseURL, var components = URLComponents( url: baseURL.appendingPathComponent("v0/management/usage-queue"), @@ -628,7 +646,15 @@ struct CLIProxyAPIUsageQueueClient: Sendable { } return date } - return try decoder.decode([CLIProxyAPIUsageRecord].self, from: data) + let decoded = try decoder.decode([LossyRecord].self, from: data) + let records = decoded.compactMap(\.value) + let malformedCount = decoded.count - records.count + if malformedCount > 0 { + Self.log.warning( + "Ignored malformed CLIProxyAPI usage records", + metadata: ["count": String(malformedCount)]) + } + return PoppedBatch(records: records, receivedCount: decoded.count) } private static func liveDataLoader(_ request: URLRequest) async throws -> (Data, URLResponse) { diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift index b270103e1b..4d3178e531 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift @@ -17,6 +17,48 @@ private actor CLIProxyAPICollectionContinuationProbe { } struct CLIProxyAPIUsageCollectorTests { + @Test + func `queue client preserves valid records around a malformed entry`() async throws { + let timestamp = try #require(CostUsageDateParser.parse("2026-07-16T12:00:00Z")) + let records = ["request-before", "request-after"].map { requestID in + CLIProxyAPIUsageRecord( + timestamp: timestamp, + provider: "codex", + model: "gpt-5.6-sol", + alias: "gpt-5.6-sol", + endpoint: "POST /v1/messages", + authType: "oauth", + requestID: requestID, + tokens: .init(input: 10, output: 20, total: 30)) + } + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let validObjects = try records.map { + try JSONSerialization.jsonObject(with: encoder.encode($0)) + } + let data = try JSONSerialization.data(withJSONObject: [ + validObjects[0], + ["timestamp": "not-a-date"], + validObjects[1], + ]) + let client = CLIProxyAPIUsageQueueClient( + settings: .init(managementKey: "management-secret"), + dataLoader: { request in + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + return (data, response) + }) + + let batch = try await client.pop(count: 100) + + #expect(batch.receivedCount == 3) + #expect(batch.records.map(\.requestID) == ["request-before", "request-after"]) + } + @Test func `persists a popped batch outside a failed cache for the next collection`() async throws { let fileManager = FileManager.default From b03ec2bbabbc921bb62c441d0b606c509b8be4f0 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 28 Jul 2026 18:11:30 -0700 Subject: [PATCH 042/183] Protect popped usage batches --- .../CLIProxyAPIUsageTelemetry.swift | 13 +++-- .../CLIProxyAPIUsageCollectorTests.swift | 48 +++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index b6a28abc50..6f1e517408 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -545,9 +545,14 @@ public enum CLIProxyAPIUsageCollector { for _ in 0.. Date: Tue, 28 Jul 2026 18:30:29 -0700 Subject: [PATCH 043/183] Quarantine unresolved proxy usage --- .../CostUsage/CostUsageScanner+Claude.swift | 10 ++-- .../CodexBarTests/CostUsageFetcherTests.swift | 56 +++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index f6cd07d612..a4d1623b08 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -891,13 +891,15 @@ extension CostUsageScanner { } let isCodexBackend = attribution?.route == .cliProxyAPI && attribution?.upstream?.isCodex == true - let isUnresolvedForeignModel = attribution?.route != .cliProxyAPI - && modelProvider != .anthropic - && modelProvider != .unknown + let isUnresolvedAttribution = if attribution?.route == .cliProxyAPI { + attribution?.upstream == nil + } else { + modelProvider != .anthropic && modelProvider != .unknown + } let includeRow = switch attributionFilter { case .all: true case .codexBackendOnly: isCodexBackend - case .excludeCodexBackend: !isCodexBackend && !isUnresolvedForeignModel + case .excludeCodexBackend: !isCodexBackend && !isUnresolvedAttribution } guard includeRow else { continue } diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index fceaff1fad..77a23896fe 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -998,6 +998,62 @@ extension CostUsageFetcherTests { #expect(claude.daily.isEmpty) } + @Test + func `proxy route without a confirmed upstream stays out of provider totals`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 24) + _ = try env.writeClaudeProjectFile( + relativePath: "unresolved-proxy/session.jsonl", + contents: env.jsonl([[ + "type": "assistant", + "timestamp": env.isoString(for: day), + "sessionId": "unresolved-proxy-session", + "requestId": "unresolved-proxy-request", + "message": [ + "id": "unresolved-proxy-message", + "model": "gpt-5.6-sol", + "usage": ["input_tokens": 100, "output_tokens": 5], + ], + ]])) + let cliProxyHome = env.root.appendingPathComponent("cli-proxy-api", isDirectory: true) + let logs = cliProxyHome.appendingPathComponent("logs", isDirectory: true) + try FileManager.default.createDirectory(at: logs, withIntermediateDirectories: true) + let proxyLog = """ + === REQUEST INFO === + URL: /v1/messages + Timestamp: \(env.isoString(for: day)) + === HEADERS === + X-Claude-Code-Session-Id: unresolved-proxy-session + === REQUEST BODY === + {"model":"gpt-5.6-sol"} + === API RESPONSE === + """ + try Data(proxyLog.utf8).write(to: logs.appendingPathComponent("request.log")) + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot, + cliProxyAPIHome: cliProxyHome) + + let codex = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + allowPricingRefresh: false, + includePiSessions: false, + scannerOptions: options) + let claude = try await CostUsageFetcher.loadTokenSnapshot( + provider: .claude, + now: day, + allowPricingRefresh: false, + includePiSessions: false, + scannerOptions: options) + + #expect(codex.daily.isEmpty) + #expect(claude.daily.isEmpty) + } + @Test func `cliproxy codex inventory keeps a session stable beyond the request log window`() async throws { let env = try CostUsageTestEnvironment() From fb9769e758603479d4d65764f179f88296c4dfc2 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 28 Jul 2026 18:49:22 -0700 Subject: [PATCH 044/183] Clear durable cost caches --- Sources/CodexBar/UsageStore+TokenCost.swift | 9 ++------ Sources/CodexBarCLI/CLICacheCommand.swift | 13 +++++------ .../CostUsageCacheLocations.swift | 15 +++++++++++++ .../CLIProxyAPIUsageCacheTests.swift | 22 +++++++++++++++++++ 4 files changed, 45 insertions(+), 14 deletions(-) create mode 100644 Sources/CodexBarCore/CostUsageCacheLocations.swift diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index a55a14e72c..08d5793035 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -428,18 +428,13 @@ extension UsageStore { nonisolated static func costUsageCacheDirectory( fileManager: FileManager = .default) -> URL { - let root = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! - return root - .appendingPathComponent("CodexBar", isDirectory: true) - .appendingPathComponent("cost-usage", isDirectory: true) + CostUsageCacheLocations.directories(fileManager: fileManager)[0] } func clearCostUsageCache() async -> String? { let errorMessage: String? = await Task.detached(priority: .utility) { let fm = FileManager.default - let cacheDirs = [ - Self.costUsageCacheDirectory(fileManager: fm), - ] + let cacheDirs = CostUsageCacheLocations.directories(fileManager: fm) for cacheDir in cacheDirs { do { diff --git a/Sources/CodexBarCLI/CLICacheCommand.swift b/Sources/CodexBarCLI/CLICacheCommand.swift index 78d63e96ec..8440a5f98c 100644 --- a/Sources/CodexBarCLI/CLICacheCommand.swift +++ b/Sources/CodexBarCLI/CLICacheCommand.swift @@ -54,15 +54,17 @@ extension CodexBarCLI { if clearCost { let fm = FileManager.default - let cacheDir = Self.costUsageCacheDirectory(fileManager: fm) var cleared = 0 var costError: String? - if fm.fileExists(atPath: cacheDir.path) { + for cacheDir in CostUsageCacheLocations.directories(fileManager: fm) + where fm.fileExists(atPath: cacheDir.path) + { do { try fm.removeItem(at: cacheDir) - cleared = 1 + cleared += 1 } catch { costError = error.localizedDescription + break } } results.append(CacheClearResult(cache: "cost", provider: nil, cleared: cleared, error: costError)) @@ -144,9 +146,6 @@ private struct CacheClearResult: Encodable { extension CodexBarCLI { /// Mirrors the cost usage cache directory used by the app (UsageStore.costUsageCacheDirectory). static func costUsageCacheDirectory(fileManager: FileManager = .default) -> URL { - let root = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! - return root - .appendingPathComponent("CodexBar", isDirectory: true) - .appendingPathComponent("cost-usage", isDirectory: true) + CostUsageCacheLocations.directories(fileManager: fileManager)[0] } } diff --git a/Sources/CodexBarCore/CostUsageCacheLocations.swift b/Sources/CodexBarCore/CostUsageCacheLocations.swift new file mode 100644 index 0000000000..9f3fb6f226 --- /dev/null +++ b/Sources/CodexBarCore/CostUsageCacheLocations.swift @@ -0,0 +1,15 @@ +import Foundation + +public enum CostUsageCacheLocations { + public static func directories(fileManager: FileManager = .default) -> [URL] { + let cacheRoot = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! + let applicationSupportRoot = fileManager.urls( + for: .applicationSupportDirectory, + in: .userDomainMask).first! + return [cacheRoot, applicationSupportRoot].map { root in + root + .appendingPathComponent("CodexBar", isDirectory: true) + .appendingPathComponent("cost-usage", isDirectory: true) + } + } +} diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index 8d26a5ada7..a607649ba3 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -3,6 +3,28 @@ import Testing @testable import CodexBarCore struct CLIProxyAPIUsageCacheTests { + @Test + func `cost cache locations include durable telemetry storage`() throws { + let fileManager = FileManager.default + let directories = CostUsageCacheLocations.directories(fileManager: fileManager) + let cacheRoot = try #require(fileManager.urls( + for: .cachesDirectory, + in: .userDomainMask).first) + let applicationSupportRoot = try #require(fileManager.urls( + for: .applicationSupportDirectory, + in: .userDomainMask).first) + + #expect(directories == [cacheRoot, applicationSupportRoot].map { root in + root + .appendingPathComponent("CodexBar", isDirectory: true) + .appendingPathComponent("cost-usage", isDirectory: true) + }) + #expect(directories.contains( + CLIProxyAPIUsageCacheIO.cacheFileURL().deletingLastPathComponent())) + #expect(directories.contains( + CLIProxyAPIUsagePendingIO.pendingFileURL().deletingLastPathComponent())) + } + @Test func `default telemetry storage is durable application support`() throws { let fileManager = FileManager.default From b5648f457408e32c08a7b98c04c4c5a2fe82e9aa Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 29 Jul 2026 16:21:00 -0700 Subject: [PATCH 045/183] Purge proxy telemetry on disconnect --- .../PreferencesSpendDashboardPane.swift | 7 ++-- Sources/CodexBar/UsageStore+CLIProxyAPI.swift | 2 + .../CLIProxyAPIUsageTelemetry.swift | 6 +-- .../CostUsageCacheLocations.swift | 30 ++++++++++++++ .../CLIProxyAPIUsageCacheTests.swift | 33 ++++++++++++++++ .../CLIProxyAPIUsageStoreTests.swift | 39 +++++++++++++++++-- 6 files changed, 107 insertions(+), 10 deletions(-) diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index bc05509c48..6a8fcf9ef0 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -293,7 +293,8 @@ struct SpendDashboardPane: View { Text( "Reading the queue removes the returned records from CLIProxyAPI. CodexBar stores a " + "sanitized local copy for cost attribution and does not retain source, account, " + - "API-key, response-header, or failure-body fields.") + "API-key, response-header, or failure-body fields. Records are retained for up to " + + "366 days; Remove and Clear cost cache delete both retained and pending records.") .font(.caption2) .foregroundStyle(.tertiary) } @@ -347,12 +348,12 @@ struct SpendDashboardPane: View { private func removeCLIProxyAPIConfiguration() { guard self.store.removeCLIProxyAPIConfiguration() else { self.store.startCLIProxyAPIUsageCollector() - self.cliProxyAPIStatus = "Could not remove the saved configuration." + self.cliProxyAPIStatus = "Could not remove the saved configuration and local telemetry." return } self.cliProxyAPIManagementKey = "" self.cliProxyAPIHasSavedConfiguration = false - self.cliProxyAPIStatus = "Configuration removed." + self.cliProxyAPIStatus = "Configuration and local telemetry removed." } private var shareAction: some View { diff --git a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift index 6b84388844..1ec5933ca2 100644 --- a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift +++ b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift @@ -26,9 +26,11 @@ extension UsageStore { @discardableResult func removeCLIProxyAPIConfiguration( + purgeTelemetry: () -> Bool = { CostUsageCacheLocations.clearCLIProxyAPIArtifacts() }, clear: () -> Bool = { CLIProxyAPIConnectionSettingsStore.clear() }) -> Bool { self.stopCLIProxyAPIUsageCollector() + guard purgeTelemetry() else { return false } return clear() } diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 6f1e517408..52b08b8d3c 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -209,14 +209,14 @@ enum CLIProxyAPIUsageCacheIO { .appendingPathComponent("CodexBar", isDirectory: true) return root .appendingPathComponent("cost-usage", isDirectory: true) - .appendingPathComponent("cliproxyapi-usage-v1.json", isDirectory: false) + .appendingPathComponent(CostUsageCacheLocations.cliProxyAPIUsageFileName, isDirectory: false) } static func legacyCacheFileURL(cacheRoot: URL? = nil) -> URL { let root = cacheRoot ?? self.defaultLegacyCacheRoot() return root .appendingPathComponent("cost-usage", isDirectory: true) - .appendingPathComponent("cliproxyapi-usage-v1.json", isDirectory: false) + .appendingPathComponent(CostUsageCacheLocations.cliProxyAPIUsageFileName, isDirectory: false) } private static let decoder: JSONDecoder = { @@ -370,7 +370,7 @@ enum CLIProxyAPIUsagePendingIO { .appendingPathComponent("CodexBar", isDirectory: true) return root .appendingPathComponent("cost-usage", isDirectory: true) - .appendingPathComponent("cliproxyapi-pending-v1.json", isDirectory: false) + .appendingPathComponent(CostUsageCacheLocations.cliProxyAPIPendingFileName, isDirectory: false) } private static let decoder: JSONDecoder = { diff --git a/Sources/CodexBarCore/CostUsageCacheLocations.swift b/Sources/CodexBarCore/CostUsageCacheLocations.swift index 9f3fb6f226..b39c6c5b37 100644 --- a/Sources/CodexBarCore/CostUsageCacheLocations.swift +++ b/Sources/CodexBarCore/CostUsageCacheLocations.swift @@ -1,6 +1,9 @@ import Foundation public enum CostUsageCacheLocations { + static let cliProxyAPIUsageFileName = "cliproxyapi-usage-v1.json" + static let cliProxyAPIPendingFileName = "cliproxyapi-pending-v1.json" + public static func directories(fileManager: FileManager = .default) -> [URL] { let cacheRoot = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! let applicationSupportRoot = fileManager.urls( @@ -12,4 +15,31 @@ public enum CostUsageCacheLocations { .appendingPathComponent("cost-usage", isDirectory: true) } } + + @discardableResult + public static func clearCLIProxyAPIArtifacts(fileManager: FileManager = .default) -> Bool { + self.clearCLIProxyAPIArtifacts( + in: self.directories(fileManager: fileManager), + fileManager: fileManager) + } + + @discardableResult + static func clearCLIProxyAPIArtifacts( + in directories: [URL], + fileManager: FileManager = .default) -> Bool + { + var succeeded = true + for directory in directories { + for name in [self.cliProxyAPIUsageFileName, self.cliProxyAPIPendingFileName] { + let url = directory.appendingPathComponent(name, isDirectory: false) + guard fileManager.fileExists(atPath: url.path) else { continue } + do { + try fileManager.removeItem(at: url) + } catch { + succeeded = false + } + } + } + return succeeded + } } diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index a607649ba3..b5d1322fbe 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -3,6 +3,39 @@ import Testing @testable import CodexBarCore struct CLIProxyAPIUsageCacheTests { + @Test + func `integration cleanup removes durable legacy and pending artifacts`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-cleanup-\(UUID().uuidString)", isDirectory: true) + let legacy = root.appendingPathComponent("legacy", isDirectory: true) + let durable = root.appendingPathComponent("durable", isDirectory: true) + defer { try? fileManager.removeItem(at: root) } + + for directory in [legacy, durable] { + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + try Data("usage".utf8).write(to: directory.appendingPathComponent( + CostUsageCacheLocations.cliProxyAPIUsageFileName)) + try Data("pending".utf8).write(to: directory.appendingPathComponent( + CostUsageCacheLocations.cliProxyAPIPendingFileName)) + } + let unrelated = durable.appendingPathComponent("claude-v6.json") + try Data("keep".utf8).write(to: unrelated) + + let cleared = CostUsageCacheLocations.clearCLIProxyAPIArtifacts( + in: [legacy, durable], + fileManager: fileManager) + + #expect(cleared) + for directory in [legacy, durable] { + #expect(!fileManager.fileExists(atPath: directory.appendingPathComponent( + CostUsageCacheLocations.cliProxyAPIUsageFileName).path)) + #expect(!fileManager.fileExists(atPath: directory.appendingPathComponent( + CostUsageCacheLocations.cliProxyAPIPendingFileName).path)) + } + #expect(fileManager.fileExists(atPath: unrelated.path)) + } + @Test func `cost cache locations include durable telemetry storage`() throws { let fileManager = FileManager.default diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift index 4437ce8217..ac0f63029d 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift @@ -83,10 +83,12 @@ struct CLIProxyAPIUsageStoreTests { store.cliProxyAPIUsageCollectorTask = task var didClearConfiguration = false - let removed = store.removeCLIProxyAPIConfiguration { - didClearConfiguration = true - return true - } + let removed = store.removeCLIProxyAPIConfiguration( + purgeTelemetry: { true }, + clear: { + didClearConfiguration = true + return true + }) await task.value #expect(removed) @@ -94,4 +96,33 @@ struct CLIProxyAPIUsageStoreTests { #expect(store.cliProxyAPIUsageCollectorTask == nil) #expect(await recorder.wasCancelled) } + + @Test + func `removing the integration preserves configuration when telemetry cleanup fails`() { + let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cliproxy-usage-store-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let environment = [ + "HOME": root.path, + "CODEX_HOME": root.appendingPathComponent(".codex", isDirectory: true).path, + ] + let store = UsageStore( + fetcher: UsageFetcher(environment: environment), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: environment) + var didClearConfiguration = false + + let removed = store.removeCLIProxyAPIConfiguration( + purgeTelemetry: { false }, + clear: { + didClearConfiguration = true + return true + }) + + #expect(!removed) + #expect(!didClearConfiguration) + } } From 9bd15e8fb1029eec63176e0b65f252edfcc5339b Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 29 Jul 2026 16:39:05 -0700 Subject: [PATCH 046/183] Complete proxy telemetry cleanup --- .../PreferencesSpendDashboardPane.swift | 11 ++++++--- Sources/CodexBar/UsageStore+CLIProxyAPI.swift | 12 ++++++---- .../CostUsageCacheLocations.swift | 10 ++++++-- .../CLIProxyAPIUsageCacheTests.swift | 23 +++++++++++++++---- .../CLIProxyAPIUsageStoreTests.swift | 19 +++++++++++---- 5 files changed, 58 insertions(+), 17 deletions(-) diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index 6a8fcf9ef0..a4e987856e 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -273,7 +273,9 @@ struct SpendDashboardPane: View { if self.cliProxyAPIHasSavedConfiguration { Button("Remove", role: .destructive) { - self.removeCLIProxyAPIConfiguration() + Task { + await self.removeCLIProxyAPIConfiguration() + } } .disabled(self.cliProxyAPIIsSaving) } @@ -345,8 +347,11 @@ struct SpendDashboardPane: View { self.store.startCLIProxyAPIUsageCollector() } - private func removeCLIProxyAPIConfiguration() { - guard self.store.removeCLIProxyAPIConfiguration() else { + private func removeCLIProxyAPIConfiguration() async { + self.cliProxyAPIIsSaving = true + defer { self.cliProxyAPIIsSaving = false } + + guard await self.store.removeCLIProxyAPIConfiguration() else { self.store.startCLIProxyAPIUsageCollector() self.cliProxyAPIStatus = "Could not remove the saved configuration and local telemetry." return diff --git a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift index 1ec5933ca2..ee711710ca 100644 --- a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift +++ b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift @@ -19,17 +19,21 @@ extension UsageStore { } } - func stopCLIProxyAPIUsageCollector() { - self.cliProxyAPIUsageCollectorTask?.cancel() + @discardableResult + func stopCLIProxyAPIUsageCollector() -> Task? { + let task = self.cliProxyAPIUsageCollectorTask + task?.cancel() self.cliProxyAPIUsageCollectorTask = nil + return task } @discardableResult func removeCLIProxyAPIConfiguration( purgeTelemetry: () -> Bool = { CostUsageCacheLocations.clearCLIProxyAPIArtifacts() }, - clear: () -> Bool = { CLIProxyAPIConnectionSettingsStore.clear() }) -> Bool + clear: () -> Bool = { CLIProxyAPIConnectionSettingsStore.clear() }) async -> Bool { - self.stopCLIProxyAPIUsageCollector() + let collectorTask = self.stopCLIProxyAPIUsageCollector() + await collectorTask?.value guard purgeTelemetry() else { return false } return clear() } diff --git a/Sources/CodexBarCore/CostUsageCacheLocations.swift b/Sources/CodexBarCore/CostUsageCacheLocations.swift index b39c6c5b37..a784d3e0ad 100644 --- a/Sources/CodexBarCore/CostUsageCacheLocations.swift +++ b/Sources/CodexBarCore/CostUsageCacheLocations.swift @@ -30,8 +30,14 @@ public enum CostUsageCacheLocations { { var succeeded = true for directory in directories { - for name in [self.cliProxyAPIUsageFileName, self.cliProxyAPIPendingFileName] { - let url = directory.appendingPathComponent(name, isDirectory: false) + let urls = [ + directory.appendingPathComponent(self.cliProxyAPIUsageFileName, isDirectory: false), + directory.appendingPathComponent(self.cliProxyAPIPendingFileName, isDirectory: false), + CostUsageCacheIO.cacheFileURL( + provider: .claude, + cacheRoot: directory.deletingLastPathComponent()), + ] + for url in urls { guard fileManager.fileExists(atPath: url.path) else { continue } do { try fileManager.removeItem(at: url) diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index b5d1322fbe..7c90b4147b 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -4,12 +4,18 @@ import Testing struct CLIProxyAPIUsageCacheTests { @Test - func `integration cleanup removes durable legacy and pending artifacts`() throws { + func `integration cleanup removes telemetry pending and derived Claude cache artifacts`() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory .appendingPathComponent("cliproxy-cleanup-\(UUID().uuidString)", isDirectory: true) - let legacy = root.appendingPathComponent("legacy", isDirectory: true) - let durable = root.appendingPathComponent("durable", isDirectory: true) + let legacy = root + .appendingPathComponent("legacy", isDirectory: true) + .appendingPathComponent("CodexBar", isDirectory: true) + .appendingPathComponent("cost-usage", isDirectory: true) + let durable = root + .appendingPathComponent("durable", isDirectory: true) + .appendingPathComponent("CodexBar", isDirectory: true) + .appendingPathComponent("cost-usage", isDirectory: true) defer { try? fileManager.removeItem(at: root) } for directory in [legacy, durable] { @@ -19,7 +25,13 @@ struct CLIProxyAPIUsageCacheTests { try Data("pending".utf8).write(to: directory.appendingPathComponent( CostUsageCacheLocations.cliProxyAPIPendingFileName)) } - let unrelated = durable.appendingPathComponent("claude-v6.json") + for directory in [legacy, durable] { + let claudeCache = CostUsageCacheIO.cacheFileURL( + provider: .claude, + cacheRoot: directory.deletingLastPathComponent()) + try Data("derived attribution".utf8).write(to: claudeCache) + } + let unrelated = durable.appendingPathComponent("codex-v11.json") try Data("keep".utf8).write(to: unrelated) let cleared = CostUsageCacheLocations.clearCLIProxyAPIArtifacts( @@ -32,6 +44,9 @@ struct CLIProxyAPIUsageCacheTests { CostUsageCacheLocations.cliProxyAPIUsageFileName).path)) #expect(!fileManager.fileExists(atPath: directory.appendingPathComponent( CostUsageCacheLocations.cliProxyAPIPendingFileName).path)) + #expect(!fileManager.fileExists(atPath: CostUsageCacheIO.cacheFileURL( + provider: .claude, + cacheRoot: directory.deletingLastPathComponent()).path)) } #expect(fileManager.fileExists(atPath: unrelated.path)) } diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift index ac0f63029d..cd2c672d3a 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift @@ -74,17 +74,27 @@ struct CLIProxyAPIUsageStoreTests { startupBehavior: .testing, environmentBase: environment) let recorder = CLIProxyAPIUsageCollectorCancellationRecorder() + let collectorFinished = LockIsolated(false) let task = Task { while !Task.isCancelled { await Task.yield() } await recorder.recordCancellation() + let drainDelay = Task.detached { + try? await Task.sleep(for: .milliseconds(50)) + } + await drainDelay.value + collectorFinished.setValue(true) } store.cliProxyAPIUsageCollectorTask = task var didClearConfiguration = false + var collectorFinishedBeforePurge = false - let removed = store.removeCLIProxyAPIConfiguration( - purgeTelemetry: { true }, + let removed = await store.removeCLIProxyAPIConfiguration( + purgeTelemetry: { + collectorFinishedBeforePurge = collectorFinished.value + return true + }, clear: { didClearConfiguration = true return true @@ -93,12 +103,13 @@ struct CLIProxyAPIUsageStoreTests { #expect(removed) #expect(didClearConfiguration) + #expect(collectorFinishedBeforePurge) #expect(store.cliProxyAPIUsageCollectorTask == nil) #expect(await recorder.wasCancelled) } @Test - func `removing the integration preserves configuration when telemetry cleanup fails`() { + func `removing the integration preserves configuration when telemetry cleanup fails`() async { let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") let root = FileManager.default.temporaryDirectory .appendingPathComponent("cliproxy-usage-store-\(UUID().uuidString)", isDirectory: true) @@ -115,7 +126,7 @@ struct CLIProxyAPIUsageStoreTests { environmentBase: environment) var didClearConfiguration = false - let removed = store.removeCLIProxyAPIConfiguration( + let removed = await store.removeCLIProxyAPIConfiguration( purgeTelemetry: { false }, clear: { didClearConfiguration = true From 164bc73d30fc4f6f86c22f0cc8ec935d45356b28 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 29 Jul 2026 17:01:05 -0700 Subject: [PATCH 047/183] Harden proxy cleanup lifecycle --- .../PreferencesSpendDashboardPane.swift | 17 ++-- Sources/CodexBar/UsageStore+CLIProxyAPI.swift | 14 +++- Sources/CodexBar/UsageStore+TokenCost.swift | 44 ++++++---- .../CLIProxyAPIUsageTelemetry.swift | 15 +++- .../CostUsageCacheLocations.swift | 52 ++++++++++++ Sources/CodexBarCore/CostUsageFetcher.swift | 5 ++ .../CLIProxyAPIUsageCacheTests.swift | 32 ++++++++ .../CLIProxyAPIUsageStoreTests.swift | 81 ++++++++++++++++--- .../CodexBarTests/CostUsageFetcherTests.swift | 19 +++++ 9 files changed, 244 insertions(+), 35 deletions(-) diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index a4e987856e..1056ba8d51 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -351,14 +351,19 @@ struct SpendDashboardPane: View { self.cliProxyAPIIsSaving = true defer { self.cliProxyAPIIsSaving = false } - guard await self.store.removeCLIProxyAPIConfiguration() else { + switch await self.store.removeCLIProxyAPIConfiguration() { + case .removed: + self.cliProxyAPIManagementKey = "" + self.cliProxyAPIHasSavedConfiguration = false + self.cliProxyAPIStatus = "Configuration and local telemetry removed." + case .configurationRemovalFailed: self.store.startCLIProxyAPIUsageCollector() - self.cliProxyAPIStatus = "Could not remove the saved configuration and local telemetry." - return + self.cliProxyAPIStatus = "Could not remove the saved configuration. Local telemetry was preserved." + case .telemetryCleanupFailed: + self.cliProxyAPIManagementKey = "" + self.cliProxyAPIHasSavedConfiguration = false + self.cliProxyAPIStatus = "Configuration removed, but some local telemetry could not be deleted." } - self.cliProxyAPIManagementKey = "" - self.cliProxyAPIHasSavedConfiguration = false - self.cliProxyAPIStatus = "Configuration and local telemetry removed." } private var shareAction: some View { diff --git a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift index ee711710ca..e378e87f6a 100644 --- a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift +++ b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift @@ -1,6 +1,12 @@ import CodexBarCore import Foundation +enum CLIProxyAPIConfigurationRemovalResult: Equatable { + case removed + case configurationRemovalFailed + case telemetryCleanupFailed +} + @MainActor extension UsageStore { private static let cliProxyAPIUsageCollectionInterval: Duration = .seconds(30) @@ -30,12 +36,14 @@ extension UsageStore { @discardableResult func removeCLIProxyAPIConfiguration( purgeTelemetry: () -> Bool = { CostUsageCacheLocations.clearCLIProxyAPIArtifacts() }, - clear: () -> Bool = { CLIProxyAPIConnectionSettingsStore.clear() }) async -> Bool + clear: () -> Bool = { CLIProxyAPIConnectionSettingsStore.clear() }) async + -> CLIProxyAPIConfigurationRemovalResult { let collectorTask = self.stopCLIProxyAPIUsageCollector() await collectorTask?.value - guard purgeTelemetry() else { return false } - return clear() + guard clear() else { return .configurationRemovalFailed } + guard purgeTelemetry() else { return .telemetryCleanupFailed } + return .removed } func collectCLIProxyAPIUsageNow( diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 08d5793035..d86e8f4e47 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -431,23 +431,37 @@ extension UsageStore { CostUsageCacheLocations.directories(fileManager: fileManager)[0] } - func clearCostUsageCache() async -> String? { - let errorMessage: String? = await Task.detached(priority: .utility) { - let fm = FileManager.default - let cacheDirs = CostUsageCacheLocations.directories(fileManager: fm) - - for cacheDir in cacheDirs { - do { - try fm.removeItem(at: cacheDir) - } catch let error as NSError { - if error.domain == NSCocoaErrorDomain, error.code == NSFileNoSuchFileError { - continue + func clearCostUsageCache( + clearDirectories: (@Sendable () async -> String?)? = nil) async -> String? + { + let collectorTask = self.stopCLIProxyAPIUsageCollector() + await collectorTask?.value + defer { + if collectorTask != nil { + self.startCLIProxyAPIUsageCollector() + } + } + + let errorMessage: String? = if let clearDirectories { + await clearDirectories() + } else { + await Task.detached(priority: .utility) { + let fm = FileManager.default + let cacheDirs = CostUsageCacheLocations.directories(fileManager: fm) + + for cacheDir in cacheDirs { + do { + try fm.removeItem(at: cacheDir) + } catch let error as NSError { + if error.domain == NSCocoaErrorDomain, error.code == NSFileNoSuchFileError { + continue + } + return error.localizedDescription } - return error.localizedDescription } - } - return nil - }.value + return nil + }.value + } guard errorMessage == nil else { return errorMessage } diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 52b08b8d3c..bab06a60d8 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -438,12 +438,23 @@ public enum CLIProxyAPIConnectionSettingsStore { @discardableResult public static func save(_ settings: CLIProxyAPIConnectionSettings) -> Bool { guard settings.isConfigured else { return false } - return KeychainCacheStore.storeResult(key: self.key, entry: settings) + guard KeychainCacheStore.storeResult(key: self.key, entry: settings) else { return false } + return CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected(false) } @discardableResult public static func clear() -> Bool { - KeychainCacheStore.clear(key: self.key) + let wasDisconnected = CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected() + guard wasDisconnected || CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected(true) else { + return false + } + guard KeychainCacheStore.clear(key: self.key) else { + if !wasDisconnected { + _ = CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected(false) + } + return false + } + return true } } diff --git a/Sources/CodexBarCore/CostUsageCacheLocations.swift b/Sources/CodexBarCore/CostUsageCacheLocations.swift index a784d3e0ad..08757760e0 100644 --- a/Sources/CodexBarCore/CostUsageCacheLocations.swift +++ b/Sources/CodexBarCore/CostUsageCacheLocations.swift @@ -3,6 +3,7 @@ import Foundation public enum CostUsageCacheLocations { static let cliProxyAPIUsageFileName = "cliproxyapi-usage-v1.json" static let cliProxyAPIPendingFileName = "cliproxyapi-pending-v1.json" + private static let cliProxyAPIDisconnectedFileName = "cliproxyapi-disconnected-v1" public static func directories(fileManager: FileManager = .default) -> [URL] { let cacheRoot = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! @@ -48,4 +49,55 @@ public enum CostUsageCacheLocations { } return succeeded } + + static func isCLIProxyAPIExplicitlyDisconnected( + stateRoot: URL? = nil, + fileManager: FileManager = .default) -> Bool + { + fileManager.fileExists(atPath: self.cliProxyAPIDisconnectedURL( + stateRoot: stateRoot, + fileManager: fileManager).path) + } + + @discardableResult + static func setCLIProxyAPIExplicitlyDisconnected( + _ disconnected: Bool, + stateRoot: URL? = nil, + fileManager: FileManager = .default) -> Bool + { + let url = self.cliProxyAPIDisconnectedURL( + stateRoot: stateRoot, + fileManager: fileManager) + if disconnected { + guard !fileManager.fileExists(atPath: url.path) else { return true } + do { + try fileManager.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + try Data().write(to: url, options: [.atomic]) + return true + } catch { + return false + } + } + + guard fileManager.fileExists(atPath: url.path) else { return true } + do { + try fileManager.removeItem(at: url) + return true + } catch { + return false + } + } + + private static func cliProxyAPIDisconnectedURL( + stateRoot: URL?, + fileManager: FileManager) -> URL + { + let root = stateRoot ?? fileManager.urls( + for: .applicationSupportDirectory, + in: .userDomainMask).first! + .appendingPathComponent("CodexBar", isDirectory: true) + return root.appendingPathComponent(self.cliProxyAPIDisconnectedFileName, isDirectory: false) + } } diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 99f72a5aba..7b734ecb6f 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -1095,6 +1095,11 @@ extension CostUsageFetcher { options: CostUsageScanner.Options, fileManager: FileManager = .default) -> Bool { + guard !CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( + stateRoot: options.cacheRoot, + fileManager: fileManager) + else { return false } + if CLIProxyAPIUsageCacheIO.load(cacheRoot: options.cacheRoot).contains(where: { $0.provider.caseInsensitiveCompare("codex") == .orderedSame }) { diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index 7c90b4147b..01a90bc39d 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -51,6 +51,38 @@ struct CLIProxyAPIUsageCacheTests { #expect(fileManager.fileExists(atPath: unrelated.path)) } + @Test + func `explicit disconnect state survives artifact cleanup and can be cleared on reconnect`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-disconnect-\(UUID().uuidString)", isDirectory: true) + let costUsage = root.appendingPathComponent("cost-usage", isDirectory: true) + defer { try? fileManager.removeItem(at: root) } + + #expect(CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected( + true, + stateRoot: root, + fileManager: fileManager)) + #expect(CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( + stateRoot: root, + fileManager: fileManager)) + try fileManager.createDirectory(at: costUsage, withIntermediateDirectories: true) + #expect(CostUsageCacheLocations.clearCLIProxyAPIArtifacts( + in: [costUsage], + fileManager: fileManager)) + #expect(CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( + stateRoot: root, + fileManager: fileManager)) + + #expect(CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected( + false, + stateRoot: root, + fileManager: fileManager)) + #expect(!CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( + stateRoot: root, + fileManager: fileManager)) + } + @Test func `cost cache locations include durable telemetry storage`() throws { let fileManager = FileManager.default diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift index cd2c672d3a..f52476bdf3 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift @@ -101,7 +101,7 @@ struct CLIProxyAPIUsageStoreTests { }) await task.value - #expect(removed) + #expect(removed == .removed) #expect(didClearConfiguration) #expect(collectorFinishedBeforePurge) #expect(store.cliProxyAPIUsageCollectorTask == nil) @@ -109,7 +109,7 @@ struct CLIProxyAPIUsageStoreTests { } @Test - func `removing the integration preserves configuration when telemetry cleanup fails`() async { + func `removing the integration preserves telemetry when configuration removal fails`() async { let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") let root = FileManager.default.temporaryDirectory .appendingPathComponent("cliproxy-usage-store-\(UUID().uuidString)", isDirectory: true) @@ -124,16 +124,79 @@ struct CLIProxyAPIUsageStoreTests { settings: settings, startupBehavior: .testing, environmentBase: environment) - var didClearConfiguration = false + var didPurgeTelemetry = false let removed = await store.removeCLIProxyAPIConfiguration( - purgeTelemetry: { false }, - clear: { - didClearConfiguration = true + purgeTelemetry: { + didPurgeTelemetry = true return true - }) + }, + clear: { false }) + + #expect(removed == .configurationRemovalFailed) + #expect(!didPurgeTelemetry) + } + + @Test + func `removing the integration reports telemetry cleanup failure after configuration removal`() async { + let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cliproxy-usage-store-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let environment = [ + "HOME": root.path, + "CODEX_HOME": root.appendingPathComponent(".codex", isDirectory: true).path, + ] + let store = UsageStore( + fetcher: UsageFetcher(environment: environment), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: environment) + + let removed = await store.removeCLIProxyAPIConfiguration( + purgeTelemetry: { false }, + clear: { true }) + + #expect(removed == .telemetryCleanupFailed) + } + + @Test + func `clearing cost cache drains the active proxy collector before deletion`() async { + let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cliproxy-usage-store-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let environment = [ + "HOME": root.path, + "CODEX_HOME": root.appendingPathComponent(".codex", isDirectory: true).path, + ] + let store = UsageStore( + fetcher: UsageFetcher(environment: environment), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: environment) + let collectorFinished = LockIsolated(false) + let deletionStartedAfterDrain = LockIsolated(false) + store.cliProxyAPIUsageCollectorTask = Task { + while !Task.isCancelled { + await Task.yield() + } + let drainDelay = Task.detached { + try? await Task.sleep(for: .milliseconds(50)) + } + await drainDelay.value + collectorFinished.setValue(true) + } + + let error = await store.clearCostUsageCache(clearDirectories: { + deletionStartedAfterDrain.setValue(collectorFinished.value) + return nil + }) + store.stopCLIProxyAPIUsageCollector() - #expect(!removed) - #expect(!didClearConfiguration) + #expect(error == nil) + #expect(deletionStartedAfterDrain.value) } } diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index 77a23896fe..48cadb5f02 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -950,6 +950,25 @@ extension CostUsageFetcherTests { #expect(CostUsageFetcher.hasCodexProxyEvidence(options: options)) } + @Test + func `explicit disconnect suppresses surviving proxy logs`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let cliProxyHome = env.root.appendingPathComponent("cli-proxy-api", isDirectory: true) + let options = CostUsageScanner.Options( + cacheRoot: env.cacheRoot, + cliProxyAPIHome: cliProxyHome) + let logs = cliProxyHome.appendingPathComponent("logs", isDirectory: true) + try FileManager.default.createDirectory(at: logs, withIntermediateDirectories: true) + try Data().write(to: logs.appendingPathComponent("request.log")) + #expect(CostUsageFetcher.hasCodexProxyEvidence(options: options)) + + #expect(CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected( + true, + stateRoot: env.cacheRoot)) + #expect(!CostUsageFetcher.hasCodexProxyEvidence(options: options)) + } + @Test func `openai model without proxy evidence stays out of codex totals`() async throws { let env = try CostUsageTestEnvironment() From d093217491bcb52f01e057fb02e7c48c39872d37 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 29 Jul 2026 17:34:19 -0700 Subject: [PATCH 048/183] Finish proxy disconnect cleanup --- .../PreferencesSpendDashboardPane.swift | 3 +- .../CLIProxyAPIUsageTelemetry.swift | 41 +++++++++++-- Sources/CodexBarCore/CostUsageFetcher.swift | 23 +++++-- .../CLIProxyAPIUsageCacheTests.swift | 39 ++++++++++++ .../CodexBarTests/CostUsageFetcherTests.swift | 60 +++++++++++++++++++ 5 files changed, 153 insertions(+), 13 deletions(-) diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index 1056ba8d51..1dd9f86ae5 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -324,7 +324,6 @@ struct SpendDashboardPane: View { } self.store.stopCLIProxyAPIUsageCollector() guard CLIProxyAPIConnectionSettingsStore.save(configuration) else { - self.store.startCLIProxyAPIUsageCollector() self.cliProxyAPIStatus = "Could not save the management key." return } @@ -356,6 +355,7 @@ struct SpendDashboardPane: View { self.cliProxyAPIManagementKey = "" self.cliProxyAPIHasSavedConfiguration = false self.cliProxyAPIStatus = "Configuration and local telemetry removed." + self.controller.refresh() case .configurationRemovalFailed: self.store.startCLIProxyAPIUsageCollector() self.cliProxyAPIStatus = "Could not remove the saved configuration. Local telemetry was preserved." @@ -363,6 +363,7 @@ struct SpendDashboardPane: View { self.cliProxyAPIManagementKey = "" self.cliProxyAPIHasSavedConfiguration = false self.cliProxyAPIStatus = "Configuration removed, but some local telemetry could not be deleted." + self.controller.refresh() } } diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index bab06a60d8..ca3f9f04a2 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -429,17 +429,46 @@ public enum CLIProxyAPIConnectionSettingsStore { identifier: "cliproxyapi-management") public static func load() -> CLIProxyAPIConnectionSettings? { - switch KeychainCacheStore.load(key: self.key, as: CLIProxyAPIConnectionSettings.self) { - case let .found(settings): settings - case .missing, .temporarilyUnavailable, .invalid: nil - } + self.load( + isDisconnected: { CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected() }, + loadStored: { + switch KeychainCacheStore.load(key: self.key, as: CLIProxyAPIConnectionSettings.self) { + case let .found(settings): settings + case .missing, .temporarilyUnavailable, .invalid: nil + } + }) + } + + static func load( + isDisconnected: () -> Bool, + loadStored: () -> CLIProxyAPIConnectionSettings?) -> CLIProxyAPIConnectionSettings? + { + guard !isDisconnected() else { return nil } + return loadStored() } @discardableResult public static func save(_ settings: CLIProxyAPIConnectionSettings) -> Bool { + self.save( + settings, + store: { KeychainCacheStore.storeResult(key: self.key, entry: $0) }, + clearDisconnectedState: { CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected(false) }, + rollback: { KeychainCacheStore.clear(key: self.key) }) + } + + static func save( + _ settings: CLIProxyAPIConnectionSettings, + store: (CLIProxyAPIConnectionSettings) -> Bool, + clearDisconnectedState: () -> Bool, + rollback: () -> Bool) -> Bool + { guard settings.isConfigured else { return false } - guard KeychainCacheStore.storeResult(key: self.key, entry: settings) else { return false } - return CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected(false) + guard store(settings) else { return false } + guard clearDisconnectedState() else { + _ = rollback() + return false + } + return true } @discardableResult diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 7b734ecb6f..89506ddd4a 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -310,6 +310,10 @@ public struct CostUsageFetcher: Sendable { overrideScannerOptions, provider: provider, codexHomePath: codexHomePath) + let cliProxyAPIAttributionEnabled = Self.isCLIProxyAPIAttributionEnabled(options: options) + if !cliProxyAPIAttributionEnabled { + options.cliProxyAPIHome = nil + } // Rolling window is inclusive, so a 30-day display starts 29 days before `now`. let since = options.calendar.date(byAdding: .day, value: -(clampedHistoryDays - 1), to: now) ?? now let scopedCodexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines) @@ -328,7 +332,7 @@ public struct CostUsageFetcher: Sendable { options.claudeLogProviderFilter = allowVertexClaudeFallback ? .all : .vertexAIOnly } else if provider == .claude { options.claudeLogProviderFilter = .excludeVertexAI - options.claudeAttributionFilter = .excludeCodexBackend + options.claudeAttributionFilter = cliProxyAPIAttributionEnabled ? .excludeCodexBackend : .all } if forceRefresh || bypassScannerDebounce { options.refreshMinIntervalSeconds = 0 @@ -686,7 +690,8 @@ public struct CostUsageFetcher: Sendable { } let claudeCache = CostUsageCacheIO.load(provider: .claude, cacheRoot: options.cacheRoot) - if !claudeCache.days.isEmpty, + if Self.isCLIProxyAPIAttributionEnabled(options: options), + !claudeCache.days.isEmpty, !CostUsageScanner.requestedWindowExpandsCache(range: range, cache: claudeCache) { let attributionResolver: CLIProxyAPIAttributionResolver? = if let home = options.cliProxyAPIHome { @@ -1095,10 +1100,7 @@ extension CostUsageFetcher { options: CostUsageScanner.Options, fileManager: FileManager = .default) -> Bool { - guard !CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( - stateRoot: options.cacheRoot, - fileManager: fileManager) - else { return false } + guard self.isCLIProxyAPIAttributionEnabled(options: options, fileManager: fileManager) else { return false } if CLIProxyAPIUsageCacheIO.load(cacheRoot: options.cacheRoot).contains(where: { $0.provider.caseInsensitiveCompare("codex") == .orderedSame @@ -1131,6 +1133,15 @@ extension CostUsageFetcher { } } + private static func isCLIProxyAPIAttributionEnabled( + options: CostUsageScanner.Options, + fileManager: FileManager = .default) -> Bool + { + !CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( + stateRoot: options.cacheRoot, + fileManager: fileManager) + } + private static func finalizeCodexSupplementalScan( projects: [CostUsageProjectBreakdown], sessions: [CostUsageSessionBreakdown], diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index 01a90bc39d..c47ad38bf4 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -83,6 +83,45 @@ struct CLIProxyAPIUsageCacheTests { fileManager: fileManager)) } + @Test + func `explicit disconnect prevents saved connection settings from loading`() { + var didReadStoredSettings = false + let loaded = CLIProxyAPIConnectionSettingsStore.load( + isDisconnected: { true }, + loadStored: { + didReadStoredSettings = true + return CLIProxyAPIConnectionSettings(managementKey: "test-management-key") + }) + + #expect(loaded == nil) + #expect(!didReadStoredSettings) + } + + @Test + func `reconnect rolls back saved credentials when disconnect state cannot be cleared`() { + let settings = CLIProxyAPIConnectionSettings( + baseURL: "http://127.0.0.1:8317", + managementKey: "test-management-key") + var didStore = false + var didRollback = false + + let saved = CLIProxyAPIConnectionSettingsStore.save( + settings, + store: { _ in + didStore = true + return true + }, + clearDisconnectedState: { false }, + rollback: { + didRollback = true + return true + }) + + #expect(!saved) + #expect(didStore) + #expect(didRollback) + } + @Test func `cost cache locations include durable telemetry storage`() throws { let fileManager = FileManager.default diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index 48cadb5f02..f4e377ad06 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -969,6 +969,66 @@ extension CostUsageFetcherTests { #expect(!CostUsageFetcher.hasCodexProxyEvidence(options: options)) } + @Test + func `explicit disconnect keeps proxy shaped rows in Claude totals`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 24) + _ = try env.writeClaudeProjectFile( + relativePath: "disconnected-proxy/session.jsonl", + contents: env.jsonl([[ + "type": "assistant", + "timestamp": env.isoString(for: day), + "sessionId": "disconnected-proxy-session", + "requestId": "disconnected-proxy-request", + "message": [ + "id": "disconnected-proxy-message", + "model": "gpt-5.6-sol", + "usage": ["input_tokens": 100, "output_tokens": 5], + ], + ]])) + let cliProxyHome = env.root.appendingPathComponent("cli-proxy-api", isDirectory: true) + let logs = cliProxyHome.appendingPathComponent("logs", isDirectory: true) + try FileManager.default.createDirectory(at: logs, withIntermediateDirectories: true) + try Data(#"{"type":"codex","disabled":false}"#.utf8) + .write(to: cliProxyHome.appendingPathComponent("codex-auth.json")) + let proxyLog = """ + === REQUEST INFO === + URL: /v1/messages + Timestamp: \(env.isoString(for: day)) + === HEADERS === + X-Claude-Code-Session-Id: disconnected-proxy-session + === REQUEST BODY === + {"model":"gpt-5.6-sol"} + === API RESPONSE === + """ + try Data(proxyLog.utf8).write(to: logs.appendingPathComponent("request.log")) + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot, + cliProxyAPIHome: cliProxyHome) + #expect(CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected( + true, + stateRoot: env.cacheRoot)) + + let claude = try await CostUsageFetcher.loadTokenSnapshot( + provider: .claude, + now: day, + allowPricingRefresh: false, + includePiSessions: false, + scannerOptions: options) + let codex = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + allowPricingRefresh: false, + includePiSessions: false, + scannerOptions: options) + + #expect(claude.daily.first?.totalTokens == 105) + #expect(codex.daily.isEmpty) + } + @Test func `openai model without proxy evidence stays out of codex totals`() async throws { let env = try CostUsageTestEnvironment() From 2908743e319cfbffb66d6b661d871e0dba26a3cd Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 29 Jul 2026 18:06:22 -0700 Subject: [PATCH 049/183] Address remaining proxy review feedback --- Sources/CodexBarCLI/CLICacheCommand.swift | 21 ++-- .../CLIProxyAPIAttributionResolver.swift | 11 ++- .../CLIProxyAPIUsageTelemetry.swift | 61 +++++++++--- .../CostUsageCacheLocations.swift | 97 +++++++++++++++++++ Tests/CodexBarTests/CLICacheTests.swift | 61 ++++++++++++ .../CLIProxyAPIAttributionResolverTests.swift | 32 ++++++ .../CLIProxyAPIUsageCacheTests.swift | 23 +++++ 7 files changed, 275 insertions(+), 31 deletions(-) diff --git a/Sources/CodexBarCLI/CLICacheCommand.swift b/Sources/CodexBarCLI/CLICacheCommand.swift index 8440a5f98c..e06574c306 100644 --- a/Sources/CodexBarCLI/CLICacheCommand.swift +++ b/Sources/CodexBarCLI/CLICacheCommand.swift @@ -53,21 +53,12 @@ extension CodexBarCLI { } if clearCost { - let fm = FileManager.default - var cleared = 0 - var costError: String? - for cacheDir in CostUsageCacheLocations.directories(fileManager: fm) - where fm.fileExists(atPath: cacheDir.path) - { - do { - try fm.removeItem(at: cacheDir) - cleared += 1 - } catch { - costError = error.localizedDescription - break - } - } - results.append(CacheClearResult(cache: "cost", provider: nil, cleared: cleared, error: costError)) + let result = CostUsageCacheLocations.clearAllCostUsageCaches() + results.append(CacheClearResult( + cache: "cost", + provider: nil, + cleared: result.cleared, + error: result.errorDescription)) } switch output.format { diff --git a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift index 1951b4bddd..58ec45f7f1 100644 --- a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift +++ b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift @@ -27,6 +27,7 @@ struct CLIProxyAPIAttributionResolver: Sendable { private static let observationCache = ObservationCache() private let observationsBySessionID: [String: [Observation]] + private let observationsByCanonicalModel: [String: [Observation]] private let usageRecordsByCanonicalModel: [String: [CLIProxyAPIUsageRecord]] private let authProviders: [AuthProvider] private let hasConfiguredOpenAIAPIUpstream: Bool @@ -38,6 +39,9 @@ struct CLIProxyAPIAttributionResolver: Sendable { hasConfiguredOpenAIAPIUpstream: Bool = false) { self.observationsBySessionID = Dictionary(grouping: observations, by: \.sessionID) + self.observationsByCanonicalModel = Dictionary( + grouping: observations, + by: { Self.canonicalModel($0.model) }) self.usageRecordsByCanonicalModel = Self.indexUsageRecords(usageRecords) self.authProviders = authProviders self.hasConfiguredOpenAIAPIUpstream = hasConfiguredOpenAIAPIUpstream @@ -157,7 +161,12 @@ struct CLIProxyAPIAttributionResolver: Sendable { candidates.append(record) } } - return candidates.count == 1 ? candidates[0] : nil + guard candidates.count == 1, let candidate = candidates.first else { return nil } + let plausibleObservations = self.observationsByCanonicalModel[canonicalModel]?.filter { + guard let timestamp = $0.timestamp else { return false } + return abs(timestamp.timeIntervalSince(candidate.timestamp)) <= Self.maximumTelemetryMatchDistance + } ?? [] + return plausibleObservations.count == 1 ? candidate : nil } private static func indexUsageRecords( diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index a25ae8f3fd..f5892777a6 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -128,6 +128,12 @@ enum CLIProxyAPIUsageCacheIO { var records: [CLIProxyAPIUsageRecord] = [] } + private enum CacheReadResult { + case missing + case valid(Cache) + case invalid + } + private static let cacheLock = NSLock() private static let maximumRecordAge: TimeInterval = 366 * 24 * 60 * 60 @@ -155,7 +161,7 @@ enum CLIProxyAPIUsageCacheIO { let cutoff = now.addingTimeInterval(-self.maximumRecordAge) return self.loadCache( cacheRoot: cacheRoot, - legacyCacheRoot: legacyCacheRoot).records.filter { $0.timestamp >= cutoff } + legacyCacheRoot: legacyCacheRoot)?.records.filter { $0.timestamp >= cutoff } ?? [] } } @@ -182,9 +188,10 @@ enum CLIProxyAPIUsageCacheIO { { self.withExclusiveAccess { let cutoff = now.addingTimeInterval(-self.maximumRecordAge) - let existingCache = self.loadCache( + guard let existingCache = self.loadCache( cacheRoot: cacheRoot, legacyCacheRoot: legacyCacheRoot) + else { return nil } var byKey: [String: CLIProxyAPIUsageRecord] = [:] for record in existingCache.records where record.timestamp >= cutoff { byKey[self.recordKey(record)] = record @@ -272,15 +279,30 @@ enum CLIProxyAPIUsageCacheIO { .appendingPathComponent("CodexBar", isDirectory: true) } - private static func loadCache(cacheRoot: URL?, legacyCacheRoot: URL?) -> Cache { + private static func loadCache(cacheRoot: URL?, legacyCacheRoot: URL?) -> Cache? { let durableURL = self.cacheFileURL(cacheRoot: cacheRoot) - let durableCache = self.decodeCache(at: durableURL) + let durableCache: Cache? + switch self.readCache(at: durableURL) { + case .missing: + durableCache = nil + case let .valid(cache): + durableCache = cache + case .invalid: + return nil + } guard let legacyCacheRoot else { return durableCache ?? Cache() } let legacyURL = self.legacyCacheFileURL(cacheRoot: legacyCacheRoot) - guard legacyURL.standardizedFileURL != durableURL.standardizedFileURL, - let legacyCache = self.decodeCache(at: legacyURL) - else { return durableCache ?? Cache() } + guard legacyURL.standardizedFileURL != durableURL.standardizedFileURL else { + return durableCache ?? Cache() + } + let legacyCache: Cache + switch self.readCache(at: legacyURL) { + case let .valid(cache): + legacyCache = cache + case .missing, .invalid: + return durableCache ?? Cache() + } let migratedCache = self.mergedCaches(legacy: legacyCache, durable: durableCache) if self.save(migratedCache, cacheRoot: cacheRoot) { @@ -289,12 +311,13 @@ enum CLIProxyAPIUsageCacheIO { return migratedCache } - private static func decodeCache(at url: URL) -> Cache? { + private static func readCache(at url: URL, fileManager: FileManager = .default) -> CacheReadResult { + guard fileManager.fileExists(atPath: url.path) else { return .missing } guard let data = try? Data(contentsOf: url), let cache = try? self.decoder.decode(Cache.self, from: data), cache.version == 1 - else { return nil } - return cache + else { return .invalid } + return .valid(cache) } private static func mergedCaches(legacy: Cache, durable: Cache?) -> Cache { @@ -557,11 +580,19 @@ public enum CLIProxyAPIUsageCollector { client: CLIProxyAPIUsageQueueClient) async -> CLIProxyAPIUsageCollectionResult { await self.collectionGate.perform { - await self.collectUnserialized( - cacheRoot: cacheRoot, - pendingRoot: pendingRoot, - shouldContinue: shouldContinue, - client: client) + do { + return try await CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( + stateRoot: cacheRoot) + { + await self.collectUnserialized( + cacheRoot: cacheRoot, + pendingRoot: pendingRoot, + shouldContinue: shouldContinue, + client: client) + } + } catch { + return .failed("Could not lock CLIProxyAPI usage telemetry: \(error.localizedDescription)") + } } } diff --git a/Sources/CodexBarCore/CostUsageCacheLocations.swift b/Sources/CodexBarCore/CostUsageCacheLocations.swift index 08757760e0..bf2952fa26 100644 --- a/Sources/CodexBarCore/CostUsageCacheLocations.swift +++ b/Sources/CodexBarCore/CostUsageCacheLocations.swift @@ -1,4 +1,16 @@ import Foundation +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#endif + +public struct CostUsageCacheClearResult: Equatable, Sendable { + public let cleared: Int + public let errorDescription: String? +} public enum CostUsageCacheLocations { static let cliProxyAPIUsageFileName = "cliproxyapi-usage-v1.json" @@ -17,6 +29,63 @@ public enum CostUsageCacheLocations { } } + public static func clearAllCostUsageCaches( + fileManager: FileManager = .default) -> CostUsageCacheClearResult + { + self.clearAllCostUsageCaches( + in: self.directories(fileManager: fileManager), + stateRoot: nil, + fileManager: fileManager) + } + + static func clearAllCostUsageCaches( + in directories: [URL], + stateRoot: URL?, + fileManager: FileManager = .default) -> CostUsageCacheClearResult + { + do { + return try self.withCLIProxyAPIInterprocessLock( + stateRoot: stateRoot, + fileManager: fileManager) + { + var cleared = 0 + for directory in directories where fileManager.fileExists(atPath: directory.path) { + do { + try fileManager.removeItem(at: directory) + cleared += 1 + } catch { + return CostUsageCacheClearResult( + cleared: cleared, + errorDescription: error.localizedDescription) + } + } + return CostUsageCacheClearResult(cleared: cleared, errorDescription: nil) + } + } catch { + return CostUsageCacheClearResult(cleared: 0, errorDescription: error.localizedDescription) + } + } + + static func withCLIProxyAPIInterprocessLock( + stateRoot: URL?, + fileManager: FileManager = .default, + operation: () throws -> T) throws -> T + { + let descriptor = try self.acquireCLIProxyAPILock(stateRoot: stateRoot, fileManager: fileManager) + defer { self.releaseCLIProxyAPILock(descriptor) } + return try operation() + } + + static func withCLIProxyAPIInterprocessLock( + stateRoot: URL?, + fileManager: FileManager = .default, + operation: () async throws -> T) async throws -> T + { + let descriptor = try self.acquireCLIProxyAPILock(stateRoot: stateRoot, fileManager: fileManager) + defer { self.releaseCLIProxyAPILock(descriptor) } + return try await operation() + } + @discardableResult public static func clearCLIProxyAPIArtifacts(fileManager: FileManager = .default) -> Bool { self.clearCLIProxyAPIArtifacts( @@ -100,4 +169,32 @@ public enum CostUsageCacheLocations { .appendingPathComponent("CodexBar", isDirectory: true) return root.appendingPathComponent(self.cliProxyAPIDisconnectedFileName, isDirectory: false) } + + private static func acquireCLIProxyAPILock( + stateRoot: URL?, + fileManager: FileManager) throws -> Int32 + { + let root = stateRoot ?? fileManager.urls( + for: .applicationSupportDirectory, + in: .userDomainMask).first! + .appendingPathComponent("CodexBar", isDirectory: true) + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + let lockURL = root.appendingPathComponent("cliproxyapi-collection.lock", isDirectory: false) + let descriptor = open(lockURL.path, O_CREAT | O_RDWR | O_CLOEXEC, S_IRUSR | S_IWUSR) + guard descriptor >= 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + while flock(descriptor, LOCK_EX) != 0 { + guard errno == EINTR else { + close(descriptor) + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + } + return descriptor + } + + private static func releaseCLIProxyAPILock(_ descriptor: Int32) { + _ = flock(descriptor, LOCK_UN) + close(descriptor) + } } diff --git a/Tests/CodexBarTests/CLICacheTests.swift b/Tests/CodexBarTests/CLICacheTests.swift index 21ca7d869a..c5d1b03cb4 100644 --- a/Tests/CodexBarTests/CLICacheTests.swift +++ b/Tests/CodexBarTests/CLICacheTests.swift @@ -1,6 +1,9 @@ import Commander +import Dispatch +import Foundation import Testing @testable import CodexBarCLI +@testable import CodexBarCore struct CLICacheTests { @Test @@ -29,4 +32,62 @@ struct CLICacheTests { #expect(help.contains("--provider with --cookies")) #expect(help.contains("codexbar cache clear --cookies --provider claude")) } + + @Test + func `cost clear waits for the collector interprocess lock`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-cli-cost-clear-lock-\(UUID().uuidString)", isDirectory: true) + let cacheDirectory = root.appendingPathComponent("cost-usage", isDirectory: true) + try FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true) + try Data("telemetry".utf8).write(to: cacheDirectory.appendingPathComponent("usage.json")) + defer { try? FileManager.default.removeItem(at: root) } + + let lockAcquired = DispatchSemaphore(value: 0) + let releaseLock = DispatchSemaphore(value: 0) + let clearStarted = DispatchSemaphore(value: 0) + let clearFinished = DispatchSemaphore(value: 0) + let collector = Task.detached { + try await CostUsageCacheLocations.withCLIProxyAPIInterprocessLock(stateRoot: root) { + lockAcquired.signal() + _ = await Self.waitForSignal(releaseLock, timeout: .distantFuture) + } + } + #expect(await Self.waitForSignal(lockAcquired, timeout: .now() + 1)) + + let clear = Task.detached { + clearStarted.signal() + let result = CostUsageCacheLocations.clearAllCostUsageCaches( + in: [cacheDirectory], + stateRoot: root, + fileManager: .default) + clearFinished.signal() + return result + } + #expect(await Self.waitForSignal(clearStarted, timeout: .now() + 1)) + #expect(!Self.waitForSignalSync(clearFinished, timeout: .now() + .milliseconds(50))) + + releaseLock.signal() + try await collector.value + let result = await clear.value + #expect(result == CostUsageCacheClearResult(cleared: 1, errorDescription: nil)) + #expect(!FileManager.default.fileExists(atPath: cacheDirectory.path)) + } + + private static func waitForSignal( + _ semaphore: DispatchSemaphore, + timeout: DispatchTime) async -> Bool + { + await withCheckedContinuation { continuation in + DispatchQueue.global().async { + continuation.resume(returning: semaphore.wait(timeout: timeout) == .success) + } + } + } + + private static func waitForSignalSync( + _ semaphore: DispatchSemaphore, + timeout: DispatchTime) -> Bool + { + semaphore.wait(timeout: timeout) == .success + } } diff --git a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift index a829b0f830..70fa167bc2 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift @@ -232,6 +232,38 @@ struct CLIProxyAPIAttributionResolverTests { #expect(!attribution.evidence.contains(.cliProxyUsageTelemetry)) } + @Test + func `telemetry plausible for two requests does not claim either upstream`() { + let timestamp = Date(timeIntervalSince1970: 1_784_179_200) + let resolver = CLIProxyAPIAttributionResolver( + observations: [ + .init(sessionID: "session-1", model: "gpt-5.6-sol", timestamp: timestamp), + .init( + sessionID: "session-2", + model: "gpt-5.6-sol", + timestamp: timestamp.addingTimeInterval(2)), + ], + usageRecords: [ + Self.record( + timestamp: timestamp.addingTimeInterval(1), + provider: "codex", + authType: "oauth"), + ]) + + for sessionID in ["session-1", "session-2"] { + let attribution = resolver.attribution( + model: "gpt-5.6-sol", + modelProvider: .openAI, + sessionID: sessionID, + timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), + tokens: Self.tokens) + + #expect(attribution.route == .cliProxyAPI) + #expect(attribution.upstream == nil) + #expect(!attribution.evidence.contains(.cliProxyUsageTelemetry)) + } + } + @Test func `failed and token mismatched telemetry are ignored`() { let timestamp = Date(timeIntervalSince1970: 1_784_179_200) diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index 7ff8832f0f..22f7cf8ded 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -320,6 +320,29 @@ struct CLIProxyAPIUsageCacheTests { == records.map { Int64($0.timestamp.timeIntervalSince1970 * 1000) }) } + @Test + func `corrupt durable cache is preserved instead of overwritten`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-corrupt-cache-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: root) } + let cacheURL = CLIProxyAPIUsageCacheIO.cacheFileURL(cacheRoot: root) + try fileManager.createDirectory( + at: cacheURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + let corruptData = Data(#"{"version":2,"records":[]}"#.utf8) + try corruptData.write(to: cacheURL) + let timestamp = try #require(CostUsageDateParser.parse("2026-07-16T12:00:00Z")) + + let result = CLIProxyAPIUsageCacheIO.merge( + [Self.record(id: "new", timestamp: timestamp)], + cacheRoot: root, + now: timestamp) + + #expect(result == nil) + #expect(try Data(contentsOf: cacheURL) == corruptData) + } + @Test func `fallback record identity survives pending journal round trips within one second`() throws { let fileManager = FileManager.default From e728e2a21dd12c25d30a4925e2bfa3125b942481 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 29 Jul 2026 18:31:47 -0700 Subject: [PATCH 050/183] Fix proxy collector lock location --- Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index f5892777a6..e0f940096f 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -582,7 +582,7 @@ public enum CLIProxyAPIUsageCollector { await self.collectionGate.perform { do { return try await CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( - stateRoot: cacheRoot) + stateRoot: cacheRoot?.deletingLastPathComponent()) { await self.collectUnserialized( cacheRoot: cacheRoot, From 6517067c4e63f7269d50f965dacdd05fa570c522 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 29 Jul 2026 18:49:27 -0700 Subject: [PATCH 051/183] Fix remaining proxy review issues --- .../PreferencesSpendDashboardPane.swift | 2 +- .../CLIProxyAPIAttributionResolver.swift | 188 ++++++++++++++++-- .../CostUsage/CostUsageScanner+Claude.swift | 35 +++- .../CLIProxyAPIAttributionBatchTests.swift | 118 +++++++++++ .../CLIProxyAPIAttributionResolverTests.swift | 6 +- ...UsageFetcherCLIProxyConcurrencyTests.swift | 118 +++++++++++ 6 files changed, 435 insertions(+), 32 deletions(-) create mode 100644 Tests/CodexBarTests/CLIProxyAPIAttributionBatchTests.swift create mode 100644 Tests/CodexBarTests/CostUsageFetcherCLIProxyConcurrencyTests.swift diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index 1dd9f86ae5..817b3ae48e 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -322,11 +322,11 @@ struct SpendDashboardPane: View { self.cliProxyAPIStatus = "Enter a loopback URL and management key." return } - self.store.stopCLIProxyAPIUsageCollector() guard CLIProxyAPIConnectionSettingsStore.save(configuration) else { self.cliProxyAPIStatus = "Could not save the management key." return } + self.store.stopCLIProxyAPIUsageCollector() self.cliProxyAPIManagementKey = "" self.cliProxyAPIHasSavedConfiguration = true diff --git a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift index 58ec45f7f1..31139fd4d8 100644 --- a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift +++ b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift @@ -19,6 +19,34 @@ struct CLIProxyAPIAttributionResolver: Sendable { let output: Int } + struct Request: Sendable { + let model: String + let modelProvider: CostUsageAttribution.ModelProvider + let sessionID: String? + let timestampUnixMs: Int64? + let tokens: TokenSignature? + } + + private struct ObservationKey: Hashable { + let sessionID: String + let canonicalModel: String + let timestamp: Date? + } + + private struct UsageRecordKey: Hashable { + let sourceID: Int + } + + private struct IndexedUsageRecord { + let sourceID: Int + let record: CLIProxyAPIUsageRecord + } + + private struct UsageRecordMatch { + let key: UsageRecordKey + let record: CLIProxyAPIUsageRecord + } + private static let requestBodyMarker = "=== REQUEST BODY ===" private static let responseMarkers = ["=== API RESPONSE ===", "=== RESPONSE ==="] private static let maxLogPrefixBytes = 2 * 1024 * 1024 @@ -28,7 +56,7 @@ struct CLIProxyAPIAttributionResolver: Sendable { private let observationsBySessionID: [String: [Observation]] private let observationsByCanonicalModel: [String: [Observation]] - private let usageRecordsByCanonicalModel: [String: [CLIProxyAPIUsageRecord]] + private let usageRecordsByCanonicalModel: [String: [IndexedUsageRecord]] private let authProviders: [AuthProvider] private let hasConfiguredOpenAIAPIUpstream: Bool @@ -75,6 +103,12 @@ struct CLIProxyAPIAttributionResolver: Sendable { timestampUnixMs: Int64?, tokens: TokenSignature?) -> CostUsageAttribution { + let request = Request( + model: model, + modelProvider: modelProvider, + sessionID: sessionID, + timestampUnixMs: timestampUnixMs, + tokens: tokens) let routeObservation = self.sessionObservation(sessionID: sessionID) let telemetryObservation = self.matchingObservation( model: model, @@ -86,8 +120,65 @@ struct CLIProxyAPIAttributionResolver: Sendable { model: model, tokens: tokens) } + return self.attribution( + request: request, + routeObservation: routeObservation, + usageRecord: usageRecord) + } + + func attributions(for requests: [Request]) -> [CostUsageAttribution] { + let prepared = requests.map { request in + let routeObservation = self.sessionObservation(sessionID: request.sessionID) + let telemetryObservation = self.matchingObservation( + model: request.model, + sessionID: request.sessionID, + timestampUnixMs: request.timestampUnixMs) + let usageRecordMatch = telemetryObservation.flatMap { + self.closestUsageRecordMatch( + observation: $0, + model: request.model, + tokens: request.tokens) + } + return ( + request: request, + routeObservation: routeObservation, + telemetryObservation: telemetryObservation, + usageRecordMatch: usageRecordMatch) + } + let matchCounts = Dictionary( + grouping: prepared.compactMap(\.usageRecordMatch?.key), + by: { $0 }).mapValues(\.count) + let representedObservations = Set(prepared.compactMap { item -> ObservationKey? in + guard item.usageRecordMatch != nil, + let observation = item.telemetryObservation + else { return nil } + return Self.observationKey(observation) + }) + + return prepared.map { item in + let usageRecord = item.usageRecordMatch.flatMap { match -> CLIProxyAPIUsageRecord? in + guard matchCounts[match.key] == 1, + self.allPlausibleObservationsRepresented( + for: match.record, + model: item.request.model, + representedObservations: representedObservations) + else { return nil } + return match.record + } + return self.attribution( + request: item.request, + routeObservation: item.routeObservation, + usageRecord: usageRecord) + } + } + + private func attribution( + request: Request, + routeObservation: Observation?, + usageRecord: CLIProxyAPIUsageRecord?) -> CostUsageAttribution + { let inventoryUpstream = routeObservation != nil && usageRecord == nil - ? self.authInventoryUpstream(model: model, modelProvider: modelProvider) + ? self.authInventoryUpstream(model: request.model, modelProvider: request.modelProvider) : nil let routeConfirmed = routeObservation != nil || inventoryUpstream != nil var evidence: Set = [.modelProvider] @@ -104,7 +195,7 @@ struct CLIProxyAPIAttributionResolver: Sendable { return CostUsageAttribution( client: .claudeCode, route: routeConfirmed ? .cliProxyAPI : .unknown, - modelProvider: modelProvider, + modelProvider: request.modelProvider, upstream: usageRecord.map(Self.upstream) ?? inventoryUpstream, evidence: evidence.sorted { $0.rawValue < $1.rawValue }) } @@ -147,55 +238,116 @@ struct CLIProxyAPIAttributionResolver: Sendable { observation: Observation, model: String, tokens: TokenSignature?) -> CLIProxyAPIUsageRecord? + { + let candidates = self.usageRecordMatches( + observation: observation, + model: model, + tokens: tokens) + guard candidates.count == 1, let candidate = candidates.first else { return nil } + return self.plausibleObservations(for: candidate.record, model: model).count == 1 + ? candidate.record + : nil + } + + private func closestUsageRecordMatch( + observation: Observation, + model: String, + tokens: TokenSignature?) -> UsageRecordMatch? { guard let observationTimestamp = observation.timestamp else { return nil } + let candidates = self.usageRecordMatches( + observation: observation, + model: model, + tokens: tokens) + return Self.uniqueClosest( + candidates, + target: observationTimestamp, + timestamp: { $0.record.timestamp }) + } + + private func usageRecordMatches( + observation: Observation, + model: String, + tokens: TokenSignature?) -> [UsageRecordMatch] + { + guard let observationTimestamp = observation.timestamp else { return [] } let canonicalModel = Self.canonicalModel(model) - guard let records = self.usageRecordsByCanonicalModel[canonicalModel] else { return nil } + guard let records = self.usageRecordsByCanonicalModel[canonicalModel] else { return [] } let earliest = observationTimestamp.addingTimeInterval(-Self.maximumTelemetryMatchDistance) let latest = observationTimestamp.addingTimeInterval(Self.maximumTelemetryMatchDistance) let startIndex = Self.firstRecordIndex(atOrAfter: earliest, in: records) - var candidates: [CLIProxyAPIUsageRecord] = [] - for record in records[startIndex...] { + var candidates: [UsageRecordMatch] = [] + for index in startIndex.. [Observation] + { + let canonicalModel = Self.canonicalModel(model) + return self.observationsByCanonicalModel[canonicalModel]?.filter { guard let timestamp = $0.timestamp else { return false } - return abs(timestamp.timeIntervalSince(candidate.timestamp)) <= Self.maximumTelemetryMatchDistance + return abs(timestamp.timeIntervalSince(record.timestamp)) <= Self.maximumTelemetryMatchDistance } ?? [] - return plausibleObservations.count == 1 ? candidate : nil + } + + private func allPlausibleObservationsRepresented( + for record: CLIProxyAPIUsageRecord, + model: String, + representedObservations: Set) -> Bool + { + let plausibleKeys = self.plausibleObservations(for: record, model: model).map(Self.observationKey) + return !plausibleKeys.isEmpty + && Set(plausibleKeys).count == plausibleKeys.count + && plausibleKeys.allSatisfy(representedObservations.contains) + } + + private static func observationKey(_ observation: Observation) -> ObservationKey { + ObservationKey( + sessionID: observation.sessionID, + canonicalModel: self.canonicalModel(observation.model), + timestamp: observation.timestamp) } private static func indexUsageRecords( - _ records: [CLIProxyAPIUsageRecord]) -> [String: [CLIProxyAPIUsageRecord]] + _ records: [CLIProxyAPIUsageRecord]) -> [String: [IndexedUsageRecord]] { - var recordsByModel: [String: [CLIProxyAPIUsageRecord]] = [:] - for record in records where !record.failed + var recordsByModel: [String: [IndexedUsageRecord]] = [:] + for (sourceID, record) in records.enumerated() where !record.failed && record.generate && record.endpoint.lowercased().contains("/v1/messages") { let models = Set([self.canonicalModel(record.alias), self.canonicalModel(record.model)]) for model in models where !model.isEmpty { - recordsByModel[model, default: []].append(record) + recordsByModel[model, default: []].append(IndexedUsageRecord( + sourceID: sourceID, + record: record)) } } return recordsByModel.mapValues { records in - records.sorted { $0.timestamp < $1.timestamp } + records.sorted { $0.record.timestamp < $1.record.timestamp } } } private static func firstRecordIndex( atOrAfter timestamp: Date, - in records: [CLIProxyAPIUsageRecord]) -> Int + in records: [IndexedUsageRecord]) -> Int { var lowerBound = 0 var upperBound = records.count while lowerBound < upperBound { let midpoint = lowerBound + (upperBound - lowerBound) / 2 - if records[midpoint].timestamp < timestamp { + if records[midpoint].record.timestamp < timestamp { lowerBound = midpoint + 1 } else { upperBound = midpoint diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index b5b758f21d..453640ebb1 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -871,7 +871,7 @@ extension CostUsageScanner { modelsDevCacheRoot: URL?) -> ClaudeReportAggregation { var result = ClaudeReportAggregation() - for row in Self.reconciledClaudeRows(cache: cache) { + let rowsWithProviders = Self.reconciledClaudeRows(cache: cache).map { row in let modelProvider = if let cachedProvider = row.attribution?.modelProvider, cachedProvider != .unknown { @@ -882,16 +882,29 @@ extension CostUsageScanner { modelsDevCatalog: modelsDevCatalog, modelsDevCacheRoot: modelsDevCacheRoot) } - let liveAttribution = attributionResolver?.attribution( - model: row.model, - modelProvider: modelProvider, - sessionID: row.sessionId, - timestampUnixMs: row.timestampUnixMs, - tokens: .init( - input: row.input, - cacheRead: row.cacheRead, - cacheCreate: row.cacheCreate, - output: row.output)) + return (row: row, modelProvider: modelProvider) + } + let liveAttributions: [CostUsageAttribution?] = if let attributionResolver { + attributionResolver.attributions(for: rowsWithProviders.map { item in + CLIProxyAPIAttributionResolver.Request( + model: item.row.model, + modelProvider: item.modelProvider, + sessionID: item.row.sessionId, + timestampUnixMs: item.row.timestampUnixMs, + tokens: .init( + input: item.row.input, + cacheRead: item.row.cacheRead, + cacheCreate: item.row.cacheCreate, + output: item.row.output)) + }).map(Optional.some) + } else { + Array(repeating: nil, count: rowsWithProviders.count) + } + + for (index, item) in rowsWithProviders.enumerated() { + let row = item.row + let modelProvider = item.modelProvider + let liveAttribution = liveAttributions[index] let attribution: CostUsageAttribution? = if liveAttribution?.route == .cliProxyAPI { liveAttribution } else if row.attribution?.route == .cliProxyAPI { diff --git a/Tests/CodexBarTests/CLIProxyAPIAttributionBatchTests.swift b/Tests/CodexBarTests/CLIProxyAPIAttributionBatchTests.swift new file mode 100644 index 0000000000..db9b37ca52 --- /dev/null +++ b/Tests/CodexBarTests/CLIProxyAPIAttributionBatchTests.swift @@ -0,0 +1,118 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CLIProxyAPIAttributionBatchTests { + @Test + func `batch attribution preserves uniquely matched concurrent proxy requests`() { + let timestamp = Date(timeIntervalSince1970: 1_784_179_200) + let otherTokens = CLIProxyAPIAttributionResolver.TokenSignature( + input: 100, + cacheRead: 300, + cacheCreate: 400, + output: 200) + let resolver = CLIProxyAPIAttributionResolver( + observations: [ + .init(sessionID: "session-1", model: "gpt-5.6-sol", timestamp: timestamp), + .init( + sessionID: "session-2", + model: "gpt-5.6-sol", + timestamp: timestamp.addingTimeInterval(2)), + ], + usageRecords: [ + Self.record( + timestamp: timestamp.addingTimeInterval(1), + provider: "codex", + authType: "oauth"), + Self.record( + timestamp: timestamp.addingTimeInterval(3), + provider: "openrouter", + authType: "api_key", + tokens: otherTokens), + ]) + let requests = [ + CLIProxyAPIAttributionResolver.Request( + model: "gpt-5.6-sol", + modelProvider: .openAI, + sessionID: "session-1", + timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), + tokens: Self.tokens), + CLIProxyAPIAttributionResolver.Request( + model: "gpt-5.6-sol", + modelProvider: .openAI, + sessionID: "session-2", + timestampUnixMs: Int64(timestamp.addingTimeInterval(2).timeIntervalSince1970 * 1000), + tokens: otherTokens), + ] + + let attributions = resolver.attributions(for: requests) + + #expect(attributions.map(\.upstream?.provider) == ["codex", "openrouter"]) + #expect(attributions.allSatisfy { $0.evidence.contains(.cliProxyUsageTelemetry) }) + } + + @Test + func `batch attribution uses unique timestamps for concurrent requests with equal tokens`() { + let timestamp = Date(timeIntervalSince1970: 1_784_179_200) + let resolver = CLIProxyAPIAttributionResolver( + observations: [ + .init(sessionID: "session-1", model: "gpt-5.6-sol", timestamp: timestamp), + .init( + sessionID: "session-2", + model: "gpt-5.6-sol", + timestamp: timestamp.addingTimeInterval(4)), + ], + usageRecords: [ + Self.record(timestamp: timestamp, provider: "codex", authType: "oauth"), + Self.record( + timestamp: timestamp.addingTimeInterval(4), + provider: "openrouter", + authType: "api_key"), + ]) + let attributions = resolver.attributions(for: [ + .init( + model: "gpt-5.6-sol", + modelProvider: .openAI, + sessionID: "session-1", + timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), + tokens: Self.tokens), + .init( + model: "gpt-5.6-sol", + modelProvider: .openAI, + sessionID: "session-2", + timestampUnixMs: Int64(timestamp.addingTimeInterval(4).timeIntervalSince1970 * 1000), + tokens: Self.tokens), + ]) + + #expect(attributions.map(\.upstream?.provider) == ["codex", "openrouter"]) + } + + private static let tokens = CLIProxyAPIAttributionResolver.TokenSignature( + input: 10, + cacheRead: 30, + cacheCreate: 40, + output: 20) + + private static func record( + timestamp: Date, + provider: String, + authType: String, + tokens: CLIProxyAPIAttributionResolver.TokenSignature = Self.tokens) -> CLIProxyAPIUsageRecord + { + CLIProxyAPIUsageRecord( + timestamp: timestamp, + provider: provider, + executorType: provider == "codex" ? "CodexExecutor" : "OpenAICompatExecutor", + model: "gpt-5.6-sol", + alias: "gpt-5.6-sol", + endpoint: "POST /v1/messages", + authType: authType, + requestID: "request-\(provider)-\(timestamp.timeIntervalSince1970)", + tokens: .init( + input: tokens.input, + output: tokens.output, + cacheRead: tokens.cacheRead, + cacheCreation: tokens.cacheCreate, + total: tokens.input + tokens.output + tokens.cacheRead + tokens.cacheCreate)) + } +} diff --git a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift index 70fa167bc2..09bbc57f21 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift @@ -250,14 +250,16 @@ struct CLIProxyAPIAttributionResolverTests { authType: "oauth"), ]) - for sessionID in ["session-1", "session-2"] { - let attribution = resolver.attribution( + let attributions = resolver.attributions(for: ["session-1", "session-2"].map { sessionID in + CLIProxyAPIAttributionResolver.Request( model: "gpt-5.6-sol", modelProvider: .openAI, sessionID: sessionID, timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), tokens: Self.tokens) + }) + for attribution in attributions { #expect(attribution.route == .cliProxyAPI) #expect(attribution.upstream == nil) #expect(!attribution.evidence.contains(.cliProxyUsageTelemetry)) diff --git a/Tests/CodexBarTests/CostUsageFetcherCLIProxyConcurrencyTests.swift b/Tests/CodexBarTests/CostUsageFetcherCLIProxyConcurrencyTests.swift new file mode 100644 index 0000000000..193eb3b80d --- /dev/null +++ b/Tests/CodexBarTests/CostUsageFetcherCLIProxyConcurrencyTests.swift @@ -0,0 +1,118 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CostUsageFetcherCLIProxyConcurrencyTests { + @Test + func `concurrent proxy requests retain distinct token matched upstreams`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 24) + func assistant( + sessionID: String, + requestID: String, + seconds: TimeInterval, + input: Int, + output: Int) -> [String: Any] + { + [ + "type": "assistant", + "timestamp": env.isoString(for: day.addingTimeInterval(seconds)), + "sessionId": sessionID, + "requestId": requestID, + "message": [ + "id": "message-\(requestID)", + "model": "gpt-5.6-sol", + "usage": ["input_tokens": input, "output_tokens": output], + ], + ] + } + _ = try env.writeClaudeProjectFile( + relativePath: "concurrent-proxy/session.jsonl", + contents: env.jsonl([ + assistant( + sessionID: "session-codex", + requestID: "codex", + seconds: 0, + input: 10, + output: 2), + assistant( + sessionID: "session-openrouter", + requestID: "openrouter", + seconds: 2, + input: 100, + output: 20), + ])) + + let cliProxyHome = env.root.appendingPathComponent("cli-proxy-api", isDirectory: true) + let logs = cliProxyHome.appendingPathComponent("logs", isDirectory: true) + try FileManager.default.createDirectory(at: logs, withIntermediateDirectories: true) + for (name, sessionID, seconds) in [ + ("codex", "session-codex", 0.0), + ("openrouter", "session-openrouter", 2.0), + ] { + let log = """ + === REQUEST INFO === + URL: /v1/messages + Timestamp: \(env.isoString(for: day.addingTimeInterval(seconds))) + === HEADERS === + X-Claude-Code-Session-Id: \(sessionID) + === REQUEST BODY === + {"model":"gpt-5.6-sol"} + === API RESPONSE === + """ + try Data(log.utf8).write(to: logs.appendingPathComponent("\(name).log")) + } + CLIProxyAPIUsageCacheIO.merge( + [ + CLIProxyAPIUsageRecord( + timestamp: day, + provider: "codex", + executorType: "CodexExecutor", + model: "gpt-5.6-sol", + alias: "gpt-5.6-sol", + endpoint: "/v1/messages", + authType: "oauth", + requestID: "cliproxy-codex", + tokens: .init(input: 10, output: 2, total: 12)), + CLIProxyAPIUsageRecord( + timestamp: day.addingTimeInterval(2), + provider: "openrouter", + executorType: "OpenAICompatExecutor", + model: "gpt-5.6-sol", + alias: "gpt-5.6-sol", + endpoint: "/v1/messages", + authType: "api_key", + requestID: "cliproxy-openrouter", + tokens: .init(input: 100, output: 20, total: 120)), + ], + cacheRoot: env.cacheRoot, + now: day.addingTimeInterval(2)) + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot, + cliProxyAPIHome: cliProxyHome) + + let codex = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + allowPricingRefresh: false, + includePiSessions: false, + scannerOptions: options) + let claude = try await CostUsageFetcher.loadTokenSnapshot( + provider: .claude, + now: day, + allowPricingRefresh: false, + includePiSessions: false, + scannerOptions: options) + + let codexBreakdown = try #require(codex.daily.first?.modelBreakdowns?.first) + let claudeBreakdown = try #require(claude.daily.first?.modelBreakdowns?.first) + #expect(codex.daily.first?.totalTokens == 12) + #expect(codexBreakdown.attribution?.upstream?.provider == "codex") + #expect(claude.daily.first?.totalTokens == 120) + #expect(claudeBreakdown.attribution?.upstream?.provider == "openrouter") + } +} From 3bf52d669785cd62b6882053c496df47b723b56a Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 29 Jul 2026 19:25:17 -0700 Subject: [PATCH 052/183] Reject stale proxy cache time zones --- Sources/CodexBarCore/CostUsageFetcher.swift | 5 ++++- Tests/CodexBarTests/CostUsageFetcherTests.swift | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 89506ddd4a..bea433bf44 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -689,7 +689,10 @@ public struct CostUsageFetcher: Sendable { } } - let claudeCache = CostUsageCacheIO.load(provider: .claude, cacheRoot: options.cacheRoot) + let claudeCache = CostUsageCacheIO.load( + provider: .claude, + cacheRoot: options.cacheRoot, + calendar: options.calendar) if Self.isCLIProxyAPIAttributionEnabled(options: options), !claudeCache.days.isEmpty, !CostUsageScanner.requestedWindowExpandsCache(range: range, cache: claudeCache) diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index a92e8493d2..8b406b49ee 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -849,6 +849,12 @@ extension CostUsageFetcherTests { #expect(cachedCodex.daily.first?.totalTokens == 135) #expect(cachedCodex.daily.first?.modelBreakdowns?.first?.attribution == codexBreakdown.attribution) + var changedTimeZoneOptions = options + let changedOffset = options.calendar.timeZone.secondsFromGMT(for: day) == 0 ? 3600 : 0 + changedTimeZoneOptions.calendar.timeZone = try #require(TimeZone(secondsFromGMT: changedOffset)) + #expect(await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, scannerOptions: changedTimeZoneOptions) == nil) + try FileManager.default.removeItem(at: cliProxyLogs) try FileManager.default.removeItem(at: cliProxyHome.appendingPathComponent("codex-auth.json")) let cachedAfterLogRotation = try #require(await CostUsageFetcher.loadCachedCodexTokenSnapshot( From 7bad61fcc6ccb0499b5817d03e73d64085f7c435 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 29 Jul 2026 19:31:17 -0700 Subject: [PATCH 053/183] Reject stale-zone proxy caches --- Sources/CodexBarCore/CostUsageFetcher.swift | 5 +- ...UsageFetcherCachedProxyTimeZoneTests.swift | 87 +++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 Tests/CodexBarTests/CostUsageFetcherCachedProxyTimeZoneTests.swift diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 89506ddd4a..e1931af495 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -689,7 +689,10 @@ public struct CostUsageFetcher: Sendable { } } - let claudeCache = CostUsageCacheIO.load(provider: .claude, cacheRoot: options.cacheRoot) + let claudeCache = CostUsageCacheIO.load( + provider: .claude, + cacheRoot: options.cacheRoot, + calendar: range.calendar) if Self.isCLIProxyAPIAttributionEnabled(options: options), !claudeCache.days.isEmpty, !CostUsageScanner.requestedWindowExpandsCache(range: range, cache: claudeCache) diff --git a/Tests/CodexBarTests/CostUsageFetcherCachedProxyTimeZoneTests.swift b/Tests/CodexBarTests/CostUsageFetcherCachedProxyTimeZoneTests.swift new file mode 100644 index 0000000000..8fb5d03833 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageFetcherCachedProxyTimeZoneTests.swift @@ -0,0 +1,87 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CostUsageFetcherCachedProxyTimeZoneTests { + @Test + func `cached codex snapshot rejects claude proxy cache from another time zone`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 24) + _ = try env.writeClaudeProjectFile( + relativePath: "proxy/session.jsonl", + contents: env.jsonl([[ + "type": "assistant", + "timestamp": env.isoString(for: day), + "sessionId": "session-proxy", + "requestId": "request-proxy", + "message": [ + "id": "message-proxy", + "model": "claude-sonnet-4-6", + "usage": ["input_tokens": 100, "output_tokens": 5], + ], + ]])) + + let proxyHome = env.root.appendingPathComponent("cli-proxy-api", isDirectory: true) + let proxyLogs = proxyHome.appendingPathComponent("logs", isDirectory: true) + try FileManager.default.createDirectory(at: proxyLogs, withIntermediateDirectories: true) + try Data(#"{"type":"codex"}"#.utf8) + .write(to: proxyHome.appendingPathComponent("codex-auth.json")) + let proxyLog = """ + === REQUEST INFO === + URL: /v1/messages + Timestamp: \(env.isoString(for: day)) + === HEADERS === + X-Claude-Code-Session-Id: session-proxy + === REQUEST BODY === + {"model":"claude-sonnet-4-6"} + === API RESPONSE === + """ + try Data(proxyLog.utf8).write(to: proxyLogs.appendingPathComponent("request.log")) + CLIProxyAPIUsageCacheIO.merge( + [ + CLIProxyAPIUsageRecord( + timestamp: day, + provider: "codex", + executorType: "CodexExecutor", + model: "gpt-5.6-sol", + alias: "claude-sonnet-4-6", + endpoint: "/v1/messages", + authType: "oauth", + requestID: "proxy-request", + tokens: .init(input: 100, output: 5, total: 105)), + ], + cacheRoot: env.cacheRoot, + now: day) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot, + cliProxyAPIHome: proxyHome) + _ = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + allowPricingRefresh: false, + includePiSessions: false, + scannerOptions: options) + + let claudeCache = CostUsageCacheIO.load(provider: .claude, cacheRoot: env.cacheRoot) + #expect(!claudeCache.days.isEmpty) + var staleZoneCalendar = options.calendar + staleZoneCalendar.timeZone = try #require(TimeZone( + identifier: options.calendar.timeZone.identifier == "UTC" ? "Asia/Bangkok" : "UTC")) + CostUsageCacheIO.save( + provider: .claude, + cache: claudeCache, + cacheRoot: env.cacheRoot, + calendar: staleZoneCalendar) + + let cachedSnapshot = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + scannerOptions: options) + + #expect(cachedSnapshot == nil) + } +} From ff3287d8eb1e2bdfb17cccd7282e5a6a3e4b89f5 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 29 Jul 2026 19:37:57 -0700 Subject: [PATCH 054/183] Use released model fixtures --- .../CLIProxyAPIAttributionBatchTests.swift | 20 ++-- .../CLIProxyAPIAttributionResolverTests.swift | 94 +++++++++---------- .../CLIProxyAPIUsageCollectorTests.swift | 16 ++-- .../CostUsageDailyReportMergeTests.swift | 8 +- ...UsageFetcherCLIProxyConcurrencyTests.swift | 12 +-- ...UsageFetcherCachedProxyTimeZoneTests.swift | 2 +- .../CodexBarTests/CostUsageFetcherTests.swift | 24 ++--- .../SpendDashboardCodexProxySourceTests.swift | 2 +- .../SpendDashboardProxyAttributionTests.swift | 16 ++-- 9 files changed, 97 insertions(+), 97 deletions(-) diff --git a/Tests/CodexBarTests/CLIProxyAPIAttributionBatchTests.swift b/Tests/CodexBarTests/CLIProxyAPIAttributionBatchTests.swift index db9b37ca52..86296deaf5 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAttributionBatchTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAttributionBatchTests.swift @@ -13,10 +13,10 @@ struct CLIProxyAPIAttributionBatchTests { output: 200) let resolver = CLIProxyAPIAttributionResolver( observations: [ - .init(sessionID: "session-1", model: "gpt-5.6-sol", timestamp: timestamp), + .init(sessionID: "session-1", model: "gpt-5.5", timestamp: timestamp), .init( sessionID: "session-2", - model: "gpt-5.6-sol", + model: "gpt-5.5", timestamp: timestamp.addingTimeInterval(2)), ], usageRecords: [ @@ -32,13 +32,13 @@ struct CLIProxyAPIAttributionBatchTests { ]) let requests = [ CLIProxyAPIAttributionResolver.Request( - model: "gpt-5.6-sol", + model: "gpt-5.5", modelProvider: .openAI, sessionID: "session-1", timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), tokens: Self.tokens), CLIProxyAPIAttributionResolver.Request( - model: "gpt-5.6-sol", + model: "gpt-5.5", modelProvider: .openAI, sessionID: "session-2", timestampUnixMs: Int64(timestamp.addingTimeInterval(2).timeIntervalSince1970 * 1000), @@ -56,10 +56,10 @@ struct CLIProxyAPIAttributionBatchTests { let timestamp = Date(timeIntervalSince1970: 1_784_179_200) let resolver = CLIProxyAPIAttributionResolver( observations: [ - .init(sessionID: "session-1", model: "gpt-5.6-sol", timestamp: timestamp), + .init(sessionID: "session-1", model: "gpt-5.5", timestamp: timestamp), .init( sessionID: "session-2", - model: "gpt-5.6-sol", + model: "gpt-5.5", timestamp: timestamp.addingTimeInterval(4)), ], usageRecords: [ @@ -71,13 +71,13 @@ struct CLIProxyAPIAttributionBatchTests { ]) let attributions = resolver.attributions(for: [ .init( - model: "gpt-5.6-sol", + model: "gpt-5.5", modelProvider: .openAI, sessionID: "session-1", timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), tokens: Self.tokens), .init( - model: "gpt-5.6-sol", + model: "gpt-5.5", modelProvider: .openAI, sessionID: "session-2", timestampUnixMs: Int64(timestamp.addingTimeInterval(4).timeIntervalSince1970 * 1000), @@ -103,8 +103,8 @@ struct CLIProxyAPIAttributionBatchTests { timestamp: timestamp, provider: provider, executorType: provider == "codex" ? "CodexExecutor" : "OpenAICompatExecutor", - model: "gpt-5.6-sol", - alias: "gpt-5.6-sol", + model: "gpt-5.5", + alias: "gpt-5.5", endpoint: "POST /v1/messages", authType: authType, requestID: "request-\(provider)-\(timestamp.timeIntervalSince1970)", diff --git a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift index 09bbc57f21..92dd0e5667 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift @@ -8,11 +8,11 @@ struct CLIProxyAPIAttributionResolverTests { let timestamp = Date(timeIntervalSince1970: 1_784_179_200) let resolver = CLIProxyAPIAttributionResolver( observations: [ - .init(sessionID: "session-1", model: "gpt-5.6-sol", timestamp: timestamp), + .init(sessionID: "session-1", model: "gpt-5.5", timestamp: timestamp), ]) let attribution = resolver.attribution( - model: "gpt-5.6-sol", + model: "gpt-5.5", modelProvider: .openAI, sessionID: "session-1", timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), @@ -28,11 +28,11 @@ struct CLIProxyAPIAttributionResolverTests { let timestamp = Date(timeIntervalSince1970: 1_784_179_200) let resolver = CLIProxyAPIAttributionResolver( observations: [ - .init(sessionID: "session-1", model: "gpt-5.6-sol", timestamp: timestamp), + .init(sessionID: "session-1", model: "gpt-5.5", timestamp: timestamp), ]) let attribution = resolver.attribution( - model: "gpt-5.6-sol", + model: "gpt-5.5", modelProvider: .openAI, sessionID: "session-1", timestampUnixMs: Int64(timestamp.addingTimeInterval(3 * 60 * 60).timeIntervalSince1970 * 1000), @@ -47,14 +47,14 @@ struct CLIProxyAPIAttributionResolverTests { func `codex auth inventory identifies upstream after this session route is proven`() { let resolver = CLIProxyAPIAttributionResolver( observations: [ - .init(sessionID: "logged-session", model: "gpt-5.6-sol", timestamp: nil), + .init(sessionID: "logged-session", model: "gpt-5.5", timestamp: nil), ], authProviders: [ .init(provider: "codex", authType: .oauth), ]) let attribution = resolver.attribution( - model: "gpt-5.6-sol", + model: "gpt-5.5", modelProvider: .openAI, sessionID: "logged-session", timestampUnixMs: nil, @@ -64,7 +64,7 @@ struct CLIProxyAPIAttributionResolverTests { #expect(attribution.upstream == .init( provider: "codex", authType: .oauth, - model: "gpt-5.6-sol")) + model: "gpt-5.5")) #expect(attribution.evidence == [ .cliProxyAuthInventory, .cliProxyRequestLog, @@ -76,14 +76,14 @@ struct CLIProxyAPIAttributionResolverTests { func `codex auth inventory does not transfer route proof between sessions`() { let resolver = CLIProxyAPIAttributionResolver( observations: [ - .init(sessionID: "logged-session", model: "gpt-5.6-sol", timestamp: nil), + .init(sessionID: "logged-session", model: "gpt-5.5", timestamp: nil), ], authProviders: [ .init(provider: "codex", authType: .oauth), ]) let attribution = resolver.attribution( - model: "gpt-5.6-sol", + model: "gpt-5.5", modelProvider: .openAI, sessionID: "unrelated-session", timestampUnixMs: nil, @@ -114,7 +114,7 @@ struct CLIProxyAPIAttributionResolverTests { let resolver = try CLIProxyAPIAttributionResolver.load(home: home, fileManager: fileManager) let attribution = resolver.attribution( - model: "gpt-5.6-sol", + model: "gpt-5.5", modelProvider: .openAI, sessionID: "session-1", timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), @@ -130,7 +130,7 @@ struct CLIProxyAPIAttributionResolverTests { let timestamp = Date(timeIntervalSince1970: 1_784_179_200) let resolver = CLIProxyAPIAttributionResolver( observations: [ - .init(sessionID: "session-1", model: "gpt-5.6-sol", timestamp: timestamp), + .init(sessionID: "session-1", model: "gpt-5.5", timestamp: timestamp), ], usageRecords: [ Self.record( @@ -140,7 +140,7 @@ struct CLIProxyAPIAttributionResolverTests { ]) let attribution = resolver.attribution( - model: "gpt-5.6-sol", + model: "gpt-5.5", modelProvider: .openAI, sessionID: "session-1", timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), @@ -149,7 +149,7 @@ struct CLIProxyAPIAttributionResolverTests { #expect(attribution.route == .cliProxyAPI) #expect(attribution.upstream?.provider == "codex") #expect(attribution.upstream?.authType == .oauth) - #expect(attribution.upstream?.model == "gpt-5.6-sol") + #expect(attribution.upstream?.model == "gpt-5.5") #expect(attribution.evidence == [ .cliProxyRequestLog, .cliProxyUsageTelemetry, @@ -162,8 +162,8 @@ struct CLIProxyAPIAttributionResolverTests { let timestamp = Date(timeIntervalSince1970: 1_784_179_200) let resolver = CLIProxyAPIAttributionResolver( observations: [ - .init(sessionID: "session-1", model: "gpt-5.6-sol", timestamp: nil), - .init(sessionID: "session-1", model: "gpt-5.6-sol", timestamp: timestamp), + .init(sessionID: "session-1", model: "gpt-5.5", timestamp: nil), + .init(sessionID: "session-1", model: "gpt-5.5", timestamp: timestamp), ], usageRecords: [ Self.record(timestamp: timestamp, provider: "openrouter", authType: "api_key"), @@ -173,7 +173,7 @@ struct CLIProxyAPIAttributionResolverTests { ]) let attribution = resolver.attribution( - model: "gpt-5.6-sol", + model: "gpt-5.5", modelProvider: .openAI, sessionID: "session-1", timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), @@ -190,14 +190,14 @@ struct CLIProxyAPIAttributionResolverTests { let timestamp = Date(timeIntervalSince1970: 1_784_179_200) let resolver = CLIProxyAPIAttributionResolver( observations: [ - .init(sessionID: "session-1", model: "gpt-5.6-sol", timestamp: timestamp), + .init(sessionID: "session-1", model: "gpt-5.5", timestamp: timestamp), ], usageRecords: [ Self.record(timestamp: timestamp, provider: "openrouter", authType: "apikey"), ]) let attribution = resolver.attribution( - model: "gpt-5.6-sol", + model: "gpt-5.5", modelProvider: .openAI, sessionID: "session-1", timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), @@ -213,7 +213,7 @@ struct CLIProxyAPIAttributionResolverTests { let timestamp = Date(timeIntervalSince1970: 1_784_179_200) let resolver = CLIProxyAPIAttributionResolver( observations: [ - .init(sessionID: "session-1", model: "gpt-5.6-sol", timestamp: timestamp), + .init(sessionID: "session-1", model: "gpt-5.5", timestamp: timestamp), ], usageRecords: [ Self.record(timestamp: timestamp, provider: "codex", authType: "oauth"), @@ -221,7 +221,7 @@ struct CLIProxyAPIAttributionResolverTests { ]) let attribution = resolver.attribution( - model: "gpt-5.6-sol", + model: "gpt-5.5", modelProvider: .openAI, sessionID: "session-1", timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), @@ -237,10 +237,10 @@ struct CLIProxyAPIAttributionResolverTests { let timestamp = Date(timeIntervalSince1970: 1_784_179_200) let resolver = CLIProxyAPIAttributionResolver( observations: [ - .init(sessionID: "session-1", model: "gpt-5.6-sol", timestamp: timestamp), + .init(sessionID: "session-1", model: "gpt-5.5", timestamp: timestamp), .init( sessionID: "session-2", - model: "gpt-5.6-sol", + model: "gpt-5.5", timestamp: timestamp.addingTimeInterval(2)), ], usageRecords: [ @@ -252,7 +252,7 @@ struct CLIProxyAPIAttributionResolverTests { let attributions = resolver.attributions(for: ["session-1", "session-2"].map { sessionID in CLIProxyAPIAttributionResolver.Request( - model: "gpt-5.6-sol", + model: "gpt-5.5", modelProvider: .openAI, sessionID: sessionID, timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), @@ -271,7 +271,7 @@ struct CLIProxyAPIAttributionResolverTests { let timestamp = Date(timeIntervalSince1970: 1_784_179_200) let resolver = CLIProxyAPIAttributionResolver( observations: [ - .init(sessionID: "session-1", model: "gpt-5.6-sol", timestamp: timestamp), + .init(sessionID: "session-1", model: "gpt-5.5", timestamp: timestamp), ], usageRecords: [ Self.record( @@ -282,8 +282,8 @@ struct CLIProxyAPIAttributionResolverTests { CLIProxyAPIUsageRecord( timestamp: timestamp.addingTimeInterval(1), provider: "codex", - model: "gpt-5.6-sol", - alias: "gpt-5.6-sol", + model: "gpt-5.5", + alias: "gpt-5.5", endpoint: "POST /v1/messages", authType: "oauth", requestID: "request-mismatch", @@ -291,7 +291,7 @@ struct CLIProxyAPIAttributionResolverTests { ]) let attribution = resolver.attribution( - model: "gpt-5.6-sol", + model: "gpt-5.5", modelProvider: .openAI, sessionID: "session-1", timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), @@ -321,19 +321,19 @@ struct CLIProxyAPIAttributionResolverTests { authType: "oauth") let resolver = CLIProxyAPIAttributionResolver( observations: [ - .init(sessionID: "session-1", model: "gpt-5.6-sol", timestamp: timestamp), + .init(sessionID: "session-1", model: "gpt-5.5", timestamp: timestamp), ], usageRecords: unrelated + [matching]) let attribution = resolver.attribution( - model: "gpt-5.6-sol", + model: "gpt-5.5", modelProvider: .openAI, sessionID: "session-1", timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), tokens: Self.tokens) #expect(attribution.upstream?.provider == "codex") - #expect(attribution.upstream?.model == "gpt-5.6-sol") + #expect(attribution.upstream?.model == "gpt-5.5") #expect(attribution.evidence.contains(.cliProxyUsageTelemetry)) } @@ -341,14 +341,14 @@ struct CLIProxyAPIAttributionResolverTests { func `model without correlated request does not claim cliproxyapi`() { let resolver = CLIProxyAPIAttributionResolver( observations: [ - .init(sessionID: "other-session", model: "gpt-5.6-sol", timestamp: nil), + .init(sessionID: "other-session", model: "gpt-5.5", timestamp: nil), ], usageRecords: [ Self.record(timestamp: Date(), provider: "codex", authType: "oauth"), ]) let attribution = resolver.attribution( - model: "gpt-5.6-sol", + model: "gpt-5.5", modelProvider: .openAI, sessionID: "session-1", timestampUnixMs: nil, @@ -378,7 +378,7 @@ struct CLIProxyAPIAttributionResolverTests { === HEADERS === X-Claude-Code-Session-Id: session-1 === REQUEST BODY === - {"model":"gpt-5.6-sol"} + {"model":"gpt-5.5"} === RESPONSE === Status: 200 """ @@ -394,7 +394,7 @@ struct CLIProxyAPIAttributionResolverTests { cacheRoot: cacheRoot, fileManager: fileManager) let attribution = resolver.attribution( - model: "gpt-5.6-sol", + model: "gpt-5.5", modelProvider: .openAI, sessionID: "session-1", timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), @@ -433,7 +433,7 @@ struct CLIProxyAPIAttributionResolverTests { let resolver = try CLIProxyAPIAttributionResolver.load(home: home, fileManager: fileManager) let attribution = resolver.attribution( - model: "gpt-5.6-sol", + model: "gpt-5.5", modelProvider: .openAI, sessionID: "target-session", timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), @@ -537,8 +537,8 @@ struct CLIProxyAPIAttributionResolverTests { "api_key":"secret-client-key", "provider":"codex", "executor_type":"CodexExecutor", - "model":"gpt-5.6-sol", - "alias":"gpt-5.6-sol", + "model":"gpt-5.5", + "alias":"gpt-5.5", "endpoint":"POST /v1/messages", "auth_type":"oauth", "request_id":"request-1", @@ -576,8 +576,8 @@ struct CLIProxyAPIAttributionResolverTests { "api_key":"secret-client-key", "provider":"codex", "executor_type":"CodexExecutor", - "model":"gpt-5.6-sol", - "alias":"gpt-5.6-sol", + "model":"gpt-5.5", + "alias":"gpt-5.5", "endpoint":"POST /v1/messages", "auth_type":"oauth", "request_id":"request-1", @@ -625,8 +625,8 @@ struct CLIProxyAPIAttributionResolverTests { let record = CLIProxyAPIUsageRecord( timestamp: timestamp.addingTimeInterval(seconds), provider: "codex", - model: "gpt-5.6-sol", - alias: "gpt-5.6-sol", + model: "gpt-5.5", + alias: "gpt-5.5", endpoint: "POST /v1/messages", authType: "oauth", requestID: requestID, @@ -682,8 +682,8 @@ struct CLIProxyAPIAttributionResolverTests { CLIProxyAPIUsageRecord( timestamp: timestamp.addingTimeInterval(TimeInterval(index)), provider: "codex", - model: "gpt-5.6-sol", - alias: "gpt-5.6-sol", + model: "gpt-5.5", + alias: "gpt-5.5", endpoint: "POST /v1/messages", authType: "oauth", requestID: "request-\(index)", @@ -832,7 +832,7 @@ struct CLIProxyAPIAttributionResolverTests { resolver: CLIProxyAPIAttributionResolver) -> CostUsageAttribution.Route { resolver.attribution( - model: "gpt-5.6-sol", + model: "gpt-5.5", modelProvider: .openAI, sessionID: sessionID, timestampUnixMs: nil, @@ -848,7 +848,7 @@ struct CLIProxyAPIAttributionResolverTests { === HEADERS === X-Claude-Code-Session-Id: \(sessionID) === REQUEST BODY === - {"model":"gpt-5.6-sol"} + {"model":"gpt-5.5"} === RESPONSE === Status: 200 """ @@ -864,8 +864,8 @@ struct CLIProxyAPIAttributionResolverTests { timestamp: timestamp, provider: provider, executorType: provider == "codex" ? "CodexExecutor" : "OpenAICompatExecutor", - model: "gpt-5.6-sol", - alias: "gpt-5.6-sol", + model: "gpt-5.5", + alias: "gpt-5.5", endpoint: "POST /v1/messages", authType: authType, requestID: "request-\(provider)-\(authType)-\(timestamp.timeIntervalSince1970)", diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift index f826644b03..418cc9f297 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift @@ -24,8 +24,8 @@ struct CLIProxyAPIUsageCollectorTests { CLIProxyAPIUsageRecord( timestamp: timestamp, provider: "codex", - model: "gpt-5.6-sol", - alias: "gpt-5.6-sol", + model: "gpt-5.5", + alias: "gpt-5.5", endpoint: "POST /v1/messages", authType: "oauth", requestID: requestID, @@ -75,8 +75,8 @@ struct CLIProxyAPIUsageCollectorTests { let record = CLIProxyAPIUsageRecord( timestamp: timestamp, provider: "codex", - model: "gpt-5.6-sol", - alias: "gpt-5.6-sol", + model: "gpt-5.5", + alias: "gpt-5.5", endpoint: "POST /v1/messages", authType: "oauth", requestID: "request-1", @@ -144,8 +144,8 @@ struct CLIProxyAPIUsageCollectorTests { let record = CLIProxyAPIUsageRecord( timestamp: timestamp, provider: "codex", - model: "gpt-5.6-sol", - alias: "gpt-5.6-sol", + model: "gpt-5.5", + alias: "gpt-5.5", endpoint: "POST /v1/messages", authType: "oauth", requestID: "request-1", @@ -187,8 +187,8 @@ struct CLIProxyAPIUsageCollectorTests { CLIProxyAPIUsageRecord( timestamp: timestamp.addingTimeInterval(TimeInterval(index)), provider: "codex", - model: "gpt-5.6-sol", - alias: "gpt-5.6-sol", + model: "gpt-5.5", + alias: "gpt-5.5", endpoint: "POST /v1/messages", authType: "oauth", requestID: "request-\(index)", diff --git a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift index 358e0454b6..5ab569a8c9 100644 --- a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift +++ b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift @@ -13,10 +13,10 @@ struct CostUsageDailyReportMergeTests { outputTokens: 10, totalTokens: 110, costUSD: 1, - modelsUsed: ["gpt-5.6-sol"], + modelsUsed: ["gpt-5.5"], modelBreakdowns: [ CostUsageDailyReport.ModelBreakdown( - modelName: "gpt-5.6-sol", + modelName: "gpt-5.5", costUSD: 1, totalTokens: 110), ]), @@ -36,10 +36,10 @@ struct CostUsageDailyReportMergeTests { outputTokens: 5, totalTokens: 55, costUSD: 0.5, - modelsUsed: ["gpt-5.6-sol"], + modelsUsed: ["gpt-5.5"], modelBreakdowns: [ CostUsageDailyReport.ModelBreakdown( - modelName: "gpt-5.6-sol", + modelName: "gpt-5.5", costUSD: 0.5, totalTokens: 55, attribution: proxyAttribution), diff --git a/Tests/CodexBarTests/CostUsageFetcherCLIProxyConcurrencyTests.swift b/Tests/CodexBarTests/CostUsageFetcherCLIProxyConcurrencyTests.swift index 193eb3b80d..e8ba386f7e 100644 --- a/Tests/CodexBarTests/CostUsageFetcherCLIProxyConcurrencyTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherCLIProxyConcurrencyTests.swift @@ -23,7 +23,7 @@ struct CostUsageFetcherCLIProxyConcurrencyTests { "requestId": requestID, "message": [ "id": "message-\(requestID)", - "model": "gpt-5.6-sol", + "model": "gpt-5.5", "usage": ["input_tokens": input, "output_tokens": output], ], ] @@ -59,7 +59,7 @@ struct CostUsageFetcherCLIProxyConcurrencyTests { === HEADERS === X-Claude-Code-Session-Id: \(sessionID) === REQUEST BODY === - {"model":"gpt-5.6-sol"} + {"model":"gpt-5.5"} === API RESPONSE === """ try Data(log.utf8).write(to: logs.appendingPathComponent("\(name).log")) @@ -70,8 +70,8 @@ struct CostUsageFetcherCLIProxyConcurrencyTests { timestamp: day, provider: "codex", executorType: "CodexExecutor", - model: "gpt-5.6-sol", - alias: "gpt-5.6-sol", + model: "gpt-5.5", + alias: "gpt-5.5", endpoint: "/v1/messages", authType: "oauth", requestID: "cliproxy-codex", @@ -80,8 +80,8 @@ struct CostUsageFetcherCLIProxyConcurrencyTests { timestamp: day.addingTimeInterval(2), provider: "openrouter", executorType: "OpenAICompatExecutor", - model: "gpt-5.6-sol", - alias: "gpt-5.6-sol", + model: "gpt-5.5", + alias: "gpt-5.5", endpoint: "/v1/messages", authType: "api_key", requestID: "cliproxy-openrouter", diff --git a/Tests/CodexBarTests/CostUsageFetcherCachedProxyTimeZoneTests.swift b/Tests/CodexBarTests/CostUsageFetcherCachedProxyTimeZoneTests.swift index 8fb5d03833..0e020f31a6 100644 --- a/Tests/CodexBarTests/CostUsageFetcherCachedProxyTimeZoneTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherCachedProxyTimeZoneTests.swift @@ -45,7 +45,7 @@ struct CostUsageFetcherCachedProxyTimeZoneTests { timestamp: day, provider: "codex", executorType: "CodexExecutor", - model: "gpt-5.6-sol", + model: "gpt-5.5", alias: "claude-sonnet-4-6", endpoint: "/v1/messages", authType: "oauth", diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index a92e8493d2..a20e69d416 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -771,7 +771,7 @@ extension CostUsageFetcherTests { timestamp: day, provider: "codex", executorType: "CodexExecutor", - model: "gpt-5.6-sol", + model: "gpt-5.5", alias: "claude-sonnet-4-6", endpoint: "/v1/messages", authType: "oauth", @@ -819,7 +819,7 @@ extension CostUsageFetcherTests { scannerOptions: options)) let expectedCodexCost = try #require(CostUsagePricing.claudeProxyCodexCostUSD( - model: "gpt-5.6-sol", + model: "gpt-5.5", inputTokens: 100, cacheReadInputTokens: 20, cacheCreationInputTokens: 10, @@ -835,7 +835,7 @@ extension CostUsageFetcherTests { upstream: .init( provider: "codex", authType: .oauth, - model: "gpt-5.6-sol", + model: "gpt-5.5", executorType: "CodexExecutor"), evidence: [.cliProxyRequestLog, .cliProxyUsageTelemetry, .modelProvider])) #expect(codex.projects.map(\.name) == ["Claude Code via CLIProxyAPI"]) @@ -873,7 +873,7 @@ extension CostUsageFetcherTests { "requestId": "request-proxy", "message": [ "id": "message-proxy", - "model": "gpt-5.6-sol", + "model": "gpt-5.5", "usage": ["input_tokens": 100, "output_tokens": 5], ], ]])) @@ -902,7 +902,7 @@ extension CostUsageFetcherTests { === HEADERS === X-Claude-Code-Session-Id: session-proxy === REQUEST BODY === - {"model":"gpt-5.6-sol"} + {"model":"gpt-5.5"} === API RESPONSE === """ try Data(proxyLog.utf8).write(to: cliProxyLogs.appendingPathComponent("request.log")) @@ -1049,7 +1049,7 @@ extension CostUsageFetcherTests { "requestId": "request-unresolved", "message": [ "id": "message-unresolved", - "model": "gpt-5.6-sol", + "model": "gpt-5.5", "usage": ["input_tokens": 100, "output_tokens": 5], ], ]])) @@ -1097,7 +1097,7 @@ extension CostUsageFetcherTests { "requestId": "unresolved-proxy-request", "message": [ "id": "unresolved-proxy-message", - "model": "gpt-5.6-sol", + "model": "gpt-5.5", "usage": ["input_tokens": 100, "output_tokens": 5], ], ]])) @@ -1111,7 +1111,7 @@ extension CostUsageFetcherTests { === HEADERS === X-Claude-Code-Session-Id: unresolved-proxy-session === REQUEST BODY === - {"model":"gpt-5.6-sol"} + {"model":"gpt-5.5"} === API RESPONSE === """ try Data(proxyLog.utf8).write(to: logs.appendingPathComponent("request.log")) @@ -1152,7 +1152,7 @@ extension CostUsageFetcherTests { "requestId": requestID, "message": [ "id": "message-\(requestID)", - "model": "gpt-5.6-sol", + "model": "gpt-5.5", "usage": ["input_tokens": 100, "output_tokens": 5], ], ] @@ -1175,7 +1175,7 @@ extension CostUsageFetcherTests { === HEADERS === X-Claude-Code-Session-Id: session-proxy === REQUEST BODY === - {"model":"gpt-5.6-sol"} + {"model":"gpt-5.5"} === API RESPONSE === """ try Data(proxyLog.utf8).write(to: cliProxyLogs.appendingPathComponent("request.log")) @@ -1201,12 +1201,12 @@ extension CostUsageFetcherTests { let breakdown = try #require(codex.daily.first?.modelBreakdowns?.first) #expect(codex.daily.first?.totalTokens == 210) #expect(codex.daily.first?.modelBreakdowns?.count == 1) - #expect(breakdown.modelName == "gpt-5.6-sol") + #expect(breakdown.modelName == "gpt-5.5") #expect(breakdown.attribution?.route == .cliProxyAPI) #expect(breakdown.attribution?.upstream == .init( provider: "codex", authType: .oauth, - model: "gpt-5.6-sol")) + model: "gpt-5.5")) #expect(breakdown.attribution?.evidence.contains(.cliProxyAuthInventory) == true) #expect(claude.daily.isEmpty) } diff --git a/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift b/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift index 54d4067789..1bbc54ca21 100644 --- a/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift +++ b/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift @@ -87,7 +87,7 @@ struct SpendDashboardCodexProxySourceTests { outputTokens: nil, totalTokens: 10, costUSD: cost, - modelsUsed: ["gpt-5.6-sol"], + modelsUsed: ["gpt-5.5"], modelBreakdowns: nil) return CostUsageTokenSnapshot( sessionTokens: 10, diff --git a/Tests/CodexBarTests/SpendDashboardProxyAttributionTests.swift b/Tests/CodexBarTests/SpendDashboardProxyAttributionTests.swift index 55e22cdf31..19f4b99e7c 100644 --- a/Tests/CodexBarTests/SpendDashboardProxyAttributionTests.swift +++ b/Tests/CodexBarTests/SpendDashboardProxyAttributionTests.swift @@ -44,10 +44,10 @@ struct SpendDashboardProxyAttributionTests { outputTokens: 10, totalTokens: 100, costUSD: 1, - modelsUsed: ["gpt-5.6-sol"], + modelsUsed: ["gpt-5.5"], modelBreakdowns: [ .init( - modelName: "gpt-5.6-sol", + modelName: "gpt-5.5", costUSD: 1, totalTokens: 100, attribution: attribution), @@ -91,7 +91,7 @@ struct SpendDashboardProxyAttributionTests { upstream: .init( provider: "codex", authType: .oauth, - model: "gpt-5.6-sol"), + model: "gpt-5.5"), evidence: [.cliProxyAuthInventory, .cliProxyRequestLog, .modelProvider]) let telemetryAttribution = CostUsageAttribution( client: .claudeCode, @@ -100,7 +100,7 @@ struct SpendDashboardProxyAttributionTests { upstream: .init( provider: "codex", authType: .oauth, - model: "openai/gpt-5.6-sol", + model: "openai/gpt-5.5", executorType: "CodexExecutor"), evidence: [.cliProxyRequestLog, .cliProxyUsageTelemetry, .modelProvider]) let entry = CostUsageDailyReport.Entry( @@ -109,15 +109,15 @@ struct SpendDashboardProxyAttributionTests { outputTokens: 100, totalTokens: 200, costUSD: 2, - modelsUsed: ["gpt-5.6-sol"], + modelsUsed: ["gpt-5.5"], modelBreakdowns: [ .init( - modelName: "gpt-5.6-sol", + modelName: "gpt-5.5", costUSD: 1, totalTokens: 100, attribution: inventoryAttribution), .init( - modelName: "gpt-5.6-sol", + modelName: "gpt-5.5", costUSD: 1, totalTokens: 100, attribution: telemetryAttribution), @@ -148,7 +148,7 @@ struct SpendDashboardProxyAttributionTests { let rows = group.models #expect(rows.count == 2) #expect(Set(rows.map(\.id)).count == 2) - #expect(rows.map(\.attribution) == [inventoryAttribution, telemetryAttribution]) + #expect(rows.map(\.attribution) == [telemetryAttribution, inventoryAttribution]) #expect(rows.map(\.rank) == [1, 2]) } } From 8fd2dd4b14c569fbd87c7ea531d0dcc4dc6c7e55 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 29 Jul 2026 20:06:33 -0700 Subject: [PATCH 055/183] Drop cached proxy attribution on disconnect --- .../CostUsage/CostUsageScanner+Claude.swift | 25 ++++-- ...ageFetcherCachedProxyDisconnectTests.swift | 90 +++++++++++++++++++ 2 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 Tests/CodexBarTests/CostUsageFetcherCachedProxyDisconnectTests.swift diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 453640ebb1..4ee1426b68 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -854,6 +854,7 @@ extension CostUsageScanner { range: range, attributionFilter: cliProxyAPIAttributionEnabled ? options.claudeAttributionFilter : .all, attributionResolver: attributionResolver, + allowCachedCLIProxyAPIAttribution: cliProxyAPIAttributionEnabled, modelsDevCatalog: modelsDevCatalog, modelsDevCacheRoot: options.cacheRoot) } @@ -863,10 +864,15 @@ extension CostUsageScanner { var repricedCosts: [ClaudeDayModelKey: ClaudeRepricedCost] = [:] } + private struct ClaudeAttributionAggregationContext { + let filter: ClaudeAttributionFilter + let resolver: CLIProxyAPIAttributionResolver? + let allowCachedCLIProxyAPIAttribution: Bool + } + private static func aggregateClaudeRows( cache: CostUsageCache, - attributionFilter: ClaudeAttributionFilter, - attributionResolver: CLIProxyAPIAttributionResolver?, + attributionContext: ClaudeAttributionAggregationContext, modelsDevCatalog: ModelsDevCatalog?, modelsDevCacheRoot: URL?) -> ClaudeReportAggregation { @@ -884,7 +890,7 @@ extension CostUsageScanner { } return (row: row, modelProvider: modelProvider) } - let liveAttributions: [CostUsageAttribution?] = if let attributionResolver { + let liveAttributions: [CostUsageAttribution?] = if let attributionResolver = attributionContext.resolver { attributionResolver.attributions(for: rowsWithProviders.map { item in CLIProxyAPIAttributionResolver.Request( model: item.row.model, @@ -907,7 +913,9 @@ extension CostUsageScanner { let liveAttribution = liveAttributions[index] let attribution: CostUsageAttribution? = if liveAttribution?.route == .cliProxyAPI { liveAttribution - } else if row.attribution?.route == .cliProxyAPI { + } else if attributionContext.allowCachedCLIProxyAPIAttribution, + row.attribution?.route == .cliProxyAPI + { row.attribution } else if modelProvider != .anthropic { liveAttribution ?? row.attribution @@ -921,7 +929,7 @@ extension CostUsageScanner { } else { modelProvider != .anthropic && modelProvider != .unknown } - let includeRow = switch attributionFilter { + let includeRow = switch attributionContext.filter { case .all: true case .codexBackendOnly: isCodexBackend case .excludeCodexBackend: !isCodexBackend && !isUnresolvedAttribution @@ -1034,6 +1042,7 @@ extension CostUsageScanner { range: CostUsageDayRange, attributionFilter: ClaudeAttributionFilter = .all, attributionResolver: CLIProxyAPIAttributionResolver? = nil, + allowCachedCLIProxyAPIAttribution: Bool = true, modelsDevCatalog: ModelsDevCatalog? = nil, modelsDevCacheRoot: URL? = nil) -> CostUsageDailyReport { @@ -1047,8 +1056,10 @@ extension CostUsageScanner { var costSeen = false let aggregation = Self.aggregateClaudeRows( cache: cache, - attributionFilter: attributionFilter, - attributionResolver: attributionResolver, + attributionContext: .init( + filter: attributionFilter, + resolver: attributionResolver, + allowCachedCLIProxyAPIAttribution: allowCachedCLIProxyAPIAttribution), modelsDevCatalog: modelsDevCatalog, modelsDevCacheRoot: modelsDevCacheRoot) let dayModels = aggregation.dayModels diff --git a/Tests/CodexBarTests/CostUsageFetcherCachedProxyDisconnectTests.swift b/Tests/CodexBarTests/CostUsageFetcherCachedProxyDisconnectTests.swift new file mode 100644 index 0000000000..fe7d4356fa --- /dev/null +++ b/Tests/CodexBarTests/CostUsageFetcherCachedProxyDisconnectTests.swift @@ -0,0 +1,90 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CostUsageFetcherCachedProxyDisconnectTests { + @Test + func `disconnect strips surviving cached proxy attribution`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 24) + _ = try env.writeClaudeProjectFile( + relativePath: "proxy/session.jsonl", + contents: env.jsonl([[ + "type": "assistant", + "timestamp": env.isoString(for: day), + "sessionId": "session-proxy", + "requestId": "request-proxy", + "message": [ + "id": "message-proxy", + "model": "claude-sonnet-4-6", + "usage": ["input_tokens": 100, "output_tokens": 5], + ], + ]])) + + let proxyHome = env.root.appendingPathComponent("cli-proxy-api", isDirectory: true) + let proxyLogs = proxyHome.appendingPathComponent("logs", isDirectory: true) + try FileManager.default.createDirectory(at: proxyLogs, withIntermediateDirectories: true) + try Data(#"{"type":"codex"}"#.utf8) + .write(to: proxyHome.appendingPathComponent("codex-auth.json")) + let proxyLog = """ + === REQUEST INFO === + URL: /v1/messages + Timestamp: \(env.isoString(for: day)) + === HEADERS === + X-Claude-Code-Session-Id: session-proxy + === REQUEST BODY === + {"model":"claude-sonnet-4-6"} + === API RESPONSE === + """ + try Data(proxyLog.utf8).write(to: proxyLogs.appendingPathComponent("request.log")) + CLIProxyAPIUsageCacheIO.merge( + [ + CLIProxyAPIUsageRecord( + timestamp: day, + provider: "codex", + executorType: "CodexExecutor", + model: "gpt-5.5", + alias: "claude-sonnet-4-6", + endpoint: "/v1/messages", + authType: "oauth", + requestID: "proxy-request", + tokens: .init(input: 100, output: 5, total: 105)), + ], + cacheRoot: env.cacheRoot, + now: day) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot, + cliProxyAPIHome: proxyHome) + let attributedCodex = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + allowPricingRefresh: false, + includePiSessions: false, + scannerOptions: options) + #expect(attributedCodex.daily.first?.totalTokens == 105) + #expect(attributedCodex.daily.first?.modelBreakdowns?.first?.attribution?.route == .cliProxyAPI) + + try FileManager.default.removeItem(at: proxyLogs) + try FileManager.default.removeItem(at: proxyHome.appendingPathComponent("codex-auth.json")) + #expect(CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected( + true, + stateRoot: env.cacheRoot)) + + let disconnectedClaude = try await CostUsageFetcher.loadTokenSnapshot( + provider: .claude, + now: day, + allowPricingRefresh: false, + includePiSessions: false, + scannerOptions: options) + #expect(disconnectedClaude.daily.first?.totalTokens == 105) + #expect(disconnectedClaude.daily.first?.modelBreakdowns?.first?.attribution == nil) + #expect(await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + scannerOptions: options) == nil) + } +} From 2907270dae146bb064de26a7ad511ee64d4e4b5d Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 29 Jul 2026 20:36:16 -0700 Subject: [PATCH 056/183] Serialize cost cache clearing --- Sources/CodexBar/UsageStore+TokenCost.swift | 22 +++----- .../CostUsageCacheLocations.swift | 2 +- .../CLIProxyAPIUsageStoreTests.swift | 51 +++++++++++++++++++ 3 files changed, 59 insertions(+), 16 deletions(-) diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index d86e8f4e47..d42dd91569 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -432,7 +432,8 @@ extension UsageStore { } func clearCostUsageCache( - clearDirectories: (@Sendable () async -> String?)? = nil) async -> String? + clearDirectories: (@Sendable () async -> String?)? = nil, + fileManager: FileManager = .default) async -> String? { let collectorTask = self.stopCLIProxyAPIUsageCollector() await collectorTask?.value @@ -442,24 +443,15 @@ extension UsageStore { } } + let cacheDirectories = CostUsageCacheLocations.directories(fileManager: fileManager) + let cliProxyAPIStateRoot = cacheDirectories[1].deletingLastPathComponent() let errorMessage: String? = if let clearDirectories { await clearDirectories() } else { await Task.detached(priority: .utility) { - let fm = FileManager.default - let cacheDirs = CostUsageCacheLocations.directories(fileManager: fm) - - for cacheDir in cacheDirs { - do { - try fm.removeItem(at: cacheDir) - } catch let error as NSError { - if error.domain == NSCocoaErrorDomain, error.code == NSFileNoSuchFileError { - continue - } - return error.localizedDescription - } - } - return nil + CostUsageCacheLocations.clearAllCostUsageCaches( + in: cacheDirectories, + stateRoot: cliProxyAPIStateRoot).errorDescription }.value } diff --git a/Sources/CodexBarCore/CostUsageCacheLocations.swift b/Sources/CodexBarCore/CostUsageCacheLocations.swift index bf2952fa26..c94289b5c9 100644 --- a/Sources/CodexBarCore/CostUsageCacheLocations.swift +++ b/Sources/CodexBarCore/CostUsageCacheLocations.swift @@ -38,7 +38,7 @@ public enum CostUsageCacheLocations { fileManager: fileManager) } - static func clearAllCostUsageCaches( + public static func clearAllCostUsageCaches( in directories: [URL], stateRoot: URL?, fileManager: FileManager = .default) -> CostUsageCacheClearResult diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift index f52476bdf3..4674ef024f 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift @@ -199,4 +199,55 @@ struct CLIProxyAPIUsageStoreTests { #expect(error == nil) #expect(deletionStartedAfterDrain.value) } + + @Test + func `clearing cost cache uses the shared locked deletion path`() async throws { + let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cliproxy-usage-store-\(UUID().uuidString)", isDirectory: true) + let fileManager = CLIProxyAPITestFileManager(root: root) + let cacheDirectory = CostUsageCacheLocations.directories(fileManager: fileManager)[0] + try FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true) + let usageFile = cacheDirectory.appendingPathComponent("usage.json") + try Data("telemetry".utf8).write(to: usageFile) + defer { try? FileManager.default.removeItem(at: root) } + + let environment = [ + "HOME": root.path, + "CODEX_HOME": root.appendingPathComponent(".codex", isDirectory: true).path, + ] + let store = UsageStore( + fetcher: UsageFetcher(environment: environment), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: environment) + let error = await store.clearCostUsageCache(fileManager: fileManager) + + #expect(error == nil) + #expect(!FileManager.default.fileExists(atPath: cacheDirectory.path)) + } +} + +private final class CLIProxyAPITestFileManager: FileManager { + private let root: URL + + init(root: URL) { + self.root = root + super.init() + } + + override func urls( + for directory: FileManager.SearchPathDirectory, + in _: FileManager.SearchPathDomainMask) -> [URL] + { + switch directory { + case .cachesDirectory: + [self.root.appendingPathComponent("Caches", isDirectory: true)] + case .applicationSupportDirectory: + [self.root.appendingPathComponent("Application Support", isDirectory: true)] + default: + super.urls(for: directory, in: .userDomainMask) + } + } } From 0b8a0f24265ee177478ffbe40fffa3d21ec21e40 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Thu, 30 Jul 2026 03:54:50 -0700 Subject: [PATCH 057/183] Fix proxy telemetry retention cleanup --- Sources/CodexBar/UsageStore+CLIProxyAPI.swift | 11 ++++- .../CLIProxyAPIUsageTelemetry.swift | 33 ++++++++++++-- .../CostUsageCacheLocations.swift | 25 ++++++++++- .../CLIProxyAPIAttributionResolverTests.swift | 5 ++- .../CLIProxyAPIUsageCacheTests.swift | 45 +++++++++++++++++++ 5 files changed, 110 insertions(+), 9 deletions(-) diff --git a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift index e378e87f6a..1bd874672b 100644 --- a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift +++ b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift @@ -35,14 +35,21 @@ extension UsageStore { @discardableResult func removeCLIProxyAPIConfiguration( - purgeTelemetry: () -> Bool = { CostUsageCacheLocations.clearCLIProxyAPIArtifacts() }, + purgeTelemetry: (() -> Bool)? = nil, clear: () -> Bool = { CLIProxyAPIConnectionSettingsStore.clear() }) async -> CLIProxyAPIConfigurationRemovalResult { let collectorTask = self.stopCLIProxyAPIUsageCollector() await collectorTask?.value guard clear() else { return .configurationRemovalFailed } - guard purgeTelemetry() else { return .telemetryCleanupFailed } + let didPurgeTelemetry: Bool = if let purgeTelemetry { + purgeTelemetry() + } else { + await Task.detached(priority: .utility) { + CostUsageCacheLocations.clearCLIProxyAPIArtifacts() + }.value + } + guard didPurgeTelemetry else { return .telemetryCleanupFailed } return .removed } diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index e0f940096f..69ff26f444 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -157,11 +157,36 @@ enum CLIProxyAPIUsageCacheIO { legacyCacheRoot: URL?, now: Date = Date()) -> [CLIProxyAPIUsageRecord] { - self.withExclusiveAccess { - let cutoff = now.addingTimeInterval(-self.maximumRecordAge) - return self.loadCache( + let cutoff = now.addingTimeInterval(-self.maximumRecordAge) + let initialSnapshot: (records: [CLIProxyAPIUsageRecord], needsPruning: Bool) = self.withExclusiveAccess { + guard let existingCache = self.loadCache( cacheRoot: cacheRoot, - legacyCacheRoot: legacyCacheRoot)?.records.filter { $0.timestamp >= cutoff } ?? [] + legacyCacheRoot: legacyCacheRoot) + else { return ([], false) } + let retainedRecords = existingCache.records.filter { $0.timestamp >= cutoff } + return (retainedRecords, retainedRecords.count != existingCache.records.count) + } + guard initialSnapshot.needsPruning else { return initialSnapshot.records } + + do { + return try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( + stateRoot: cacheRoot?.deletingLastPathComponent()) + { + self.withExclusiveAccess { + guard let currentCache = self.loadCache( + cacheRoot: cacheRoot, + legacyCacheRoot: legacyCacheRoot) + else { return [] } + let retainedRecords = currentCache.records.filter { $0.timestamp >= cutoff } + let retainedCache = Cache(records: retainedRecords) + if retainedCache != currentCache { + _ = self.save(retainedCache, cacheRoot: cacheRoot) + } + return retainedRecords + } + } + } catch { + return initialSnapshot.records } } diff --git a/Sources/CodexBarCore/CostUsageCacheLocations.swift b/Sources/CodexBarCore/CostUsageCacheLocations.swift index c94289b5c9..7079362ab2 100644 --- a/Sources/CodexBarCore/CostUsageCacheLocations.swift +++ b/Sources/CodexBarCore/CostUsageCacheLocations.swift @@ -88,15 +88,36 @@ public enum CostUsageCacheLocations { @discardableResult public static func clearCLIProxyAPIArtifacts(fileManager: FileManager = .default) -> Bool { - self.clearCLIProxyAPIArtifacts( - in: self.directories(fileManager: fileManager), + let directories = self.directories(fileManager: fileManager) + return self.clearCLIProxyAPIArtifacts( + in: directories, + stateRoot: directories[1].deletingLastPathComponent(), fileManager: fileManager) } @discardableResult static func clearCLIProxyAPIArtifacts( in directories: [URL], + stateRoot: URL?, fileManager: FileManager = .default) -> Bool + { + do { + return try self.withCLIProxyAPIInterprocessLock( + stateRoot: stateRoot, + fileManager: fileManager) + { + self.clearCLIProxyAPIArtifactsUnserialized( + in: directories, + fileManager: fileManager) + } + } catch { + return false + } + } + + private static func clearCLIProxyAPIArtifactsUnserialized( + in directories: [URL], + fileManager: FileManager) -> Bool { var succeeded = true for directory in directories { diff --git a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift index 92dd0e5667..f719f11993 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift @@ -742,7 +742,7 @@ struct CLIProxyAPIAttributionResolverTests { } @Test - func `usage cache filters expired records during load`() { + func `usage cache prunes expired records from disk during load`() { let fileManager = FileManager.default let cacheRoot = fileManager.temporaryDirectory .appendingPathComponent("cliproxy-load-retention-\(UUID().uuidString)", isDirectory: true) @@ -764,6 +764,9 @@ struct CLIProxyAPIAttributionResolverTests { #expect(CLIProxyAPIUsageCacheIO.load( cacheRoot: cacheRoot, now: now).map(\.requestID) == [current.requestID]) + #expect(CLIProxyAPIUsageCacheIO.load( + cacheRoot: cacheRoot, + now: expired.timestamp).map(\.requestID) == [current.requestID]) } @Test diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index 22f7cf8ded..2fef17f2fa 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -36,6 +36,7 @@ struct CLIProxyAPIUsageCacheTests { let cleared = CostUsageCacheLocations.clearCLIProxyAPIArtifacts( in: [legacy, durable], + stateRoot: root, fileManager: fileManager) #expect(cleared) @@ -51,6 +52,49 @@ struct CLIProxyAPIUsageCacheTests { #expect(fileManager.fileExists(atPath: unrelated.path)) } + @Test + func `integration cleanup waits for the collector interprocess lock`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cliproxy-cleanup-lock-\(UUID().uuidString)", isDirectory: true) + let costUsage = root.appendingPathComponent("cost-usage", isDirectory: true) + try FileManager.default.createDirectory(at: costUsage, withIntermediateDirectories: true) + let usageFile = costUsage.appendingPathComponent(CostUsageCacheLocations.cliProxyAPIUsageFileName) + try Data("telemetry".utf8).write(to: usageFile) + defer { try? FileManager.default.removeItem(at: root) } + + let lockAcquired = DispatchSemaphore(value: 0) + let releaseLock = DispatchSemaphore(value: 0) + let clearStarted = DispatchSemaphore(value: 0) + let clearFinished = DispatchSemaphore(value: 0) + let collector = Task.detached { + try await CostUsageCacheLocations.withCLIProxyAPIInterprocessLock(stateRoot: root) { + lockAcquired.signal() + _ = await Self.waitForSignal(releaseLock, timeout: .distantFuture) + } + } + #expect(await Self.waitForSignal(lockAcquired, timeout: .now() + 1)) + + let clear = Task.detached { + clearStarted.signal() + let result = CostUsageCacheLocations.clearCLIProxyAPIArtifacts( + in: [costUsage], + stateRoot: root, + fileManager: .default) + clearFinished.signal() + return result + } + #expect(await Self.waitForSignal(clearStarted, timeout: .now() + 1)) + let finishedBeforeRelease = await Self.waitForSignal( + clearFinished, + timeout: .now() + .milliseconds(50)) + #expect(!finishedBeforeRelease) + + releaseLock.signal() + try await collector.value + #expect(await clear.value) + #expect(!FileManager.default.fileExists(atPath: usageFile.path)) + } + @Test func `explicit disconnect state survives artifact cleanup and can be cleared on reconnect`() throws { let fileManager = FileManager.default @@ -69,6 +113,7 @@ struct CLIProxyAPIUsageCacheTests { try fileManager.createDirectory(at: costUsage, withIntermediateDirectories: true) #expect(CostUsageCacheLocations.clearCLIProxyAPIArtifacts( in: [costUsage], + stateRoot: root, fileManager: fileManager)) #expect(CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( stateRoot: root, From 71592d4ff1eff0f8b79ba8aa04df555c65332c72 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Thu, 30 Jul 2026 04:18:11 -0700 Subject: [PATCH 058/183] Recheck proxy configuration before collection --- .../CLIProxyAPIUsageTelemetry.swift | 15 +++-- .../CLIProxyAPIUsageCollectorTests.swift | 58 +++++++++++++++++++ 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 69ff26f444..aa10b6009f 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -588,12 +588,14 @@ public enum CLIProxyAPIUsageCollector { shouldContinue: @escaping @Sendable () async -> Bool = { true }) async -> CLIProxyAPIUsageCollectionResult { - guard !CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected(stateRoot: cacheRoot) else { - return .notConfigured - } guard let settings, settings.isConfigured else { return .notConfigured } return await self.collect( cacheRoot: cacheRoot, + configurationIsCurrent: { + guard !CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected(stateRoot: cacheRoot) + else { return false } + return CLIProxyAPIConnectionSettingsStore.load() == settings + }, shouldContinue: shouldContinue, client: CLIProxyAPIUsageQueueClient(settings: settings)) } @@ -601,15 +603,18 @@ public enum CLIProxyAPIUsageCollector { static func collect( cacheRoot: URL? = nil, pendingRoot: URL? = nil, + configurationIsCurrent: @escaping @Sendable () -> Bool = { true }, shouldContinue: @escaping @Sendable () async -> Bool = { true }, client: CLIProxyAPIUsageQueueClient) async -> CLIProxyAPIUsageCollectionResult { - await self.collectionGate.perform { + guard configurationIsCurrent() else { return .notConfigured } + return await self.collectionGate.perform { do { return try await CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( stateRoot: cacheRoot?.deletingLastPathComponent()) { - await self.collectUnserialized( + guard configurationIsCurrent() else { return .notConfigured } + return await self.collectUnserialized( cacheRoot: cacheRoot, pendingRoot: pendingRoot, shouldContinue: shouldContinue, diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift index 418cc9f297..64ec584270 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift @@ -222,4 +222,62 @@ struct CLIProxyAPIUsageCollectorTests { #expect(await probe.popCount == 1) #expect(CLIProxyAPIUsageCacheIO.load(cacheRoot: cacheRoot).count == 100) } + + @Test + func `collector rechecks configuration after acquiring the interprocess lock`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cliproxy-queued-disconnect-\(UUID().uuidString)", isDirectory: true) + let cacheRoot = root.appendingPathComponent("cost-usage", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let lockAcquired = DispatchSemaphore(value: 0) + let releaseLock = DispatchSemaphore(value: 0) + let configurationChecked = DispatchSemaphore(value: 0) + let popProbe = CLIProxyAPICollectionContinuationProbe() + let lockHolder = Task.detached { + try await CostUsageCacheLocations.withCLIProxyAPIInterprocessLock(stateRoot: root) { + lockAcquired.signal() + _ = await Self.waitForSignal(releaseLock, timeout: .distantFuture) + } + } + #expect(await Self.waitForSignal(lockAcquired, timeout: .now() + 1)) + let client = CLIProxyAPIUsageQueueClient( + settings: .init(managementKey: "management-secret"), + dataLoader: { request in + await popProbe.recordPop() + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + return (Data("[]".utf8), response) + }) + let collection = Task.detached { + await CLIProxyAPIUsageCollector.collect( + cacheRoot: cacheRoot, + configurationIsCurrent: { + configurationChecked.signal() + return !CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected(stateRoot: root) + }, + client: client) + } + #expect(await Self.waitForSignal(configurationChecked, timeout: .now() + 1)) + #expect(CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected(true, stateRoot: root)) + releaseLock.signal() + + try await lockHolder.value + #expect(await collection.value == .notConfigured) + #expect(await popProbe.popCount == 0) + } + + private static func waitForSignal( + _ semaphore: DispatchSemaphore, + timeout: DispatchTime) async -> Bool + { + await withCheckedContinuation { continuation in + DispatchQueue.global().async { + continuation.resume(returning: semaphore.wait(timeout: timeout) == .success) + } + } + } } From 20ada6b229419ddb0764801b3ca95c44a78d5542 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Thu, 30 Jul 2026 04:34:01 -0700 Subject: [PATCH 059/183] Serialize proxy telemetry migration --- .../CLIProxyAPIUsageTelemetry.swift | 46 ++++++++++++++++++- .../CLIProxyAPIUsageCacheTests.swift | 31 ++++--------- 2 files changed, 53 insertions(+), 24 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index aa10b6009f..1d82f6e389 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -158,10 +158,40 @@ enum CLIProxyAPIUsageCacheIO { now: Date = Date()) -> [CLIProxyAPIUsageRecord] { let cutoff = now.addingTimeInterval(-self.maximumRecordAge) + if self.hasLegacyCacheToMigrate( + cacheRoot: cacheRoot, + legacyCacheRoot: legacyCacheRoot) + { + do { + return try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( + stateRoot: cacheRoot?.deletingLastPathComponent()) + { + self.withExclusiveAccess { + guard let currentCache = self.loadCache( + cacheRoot: cacheRoot, + legacyCacheRoot: legacyCacheRoot) + else { return [] } + let retainedRecords = currentCache.records.filter { $0.timestamp >= cutoff } + let retainedCache = Cache(records: retainedRecords) + if retainedCache != currentCache { + _ = self.save(retainedCache, cacheRoot: cacheRoot) + } + return retainedRecords + } + } + } catch { + return self.withExclusiveAccess { + self.loadCache( + cacheRoot: cacheRoot, + legacyCacheRoot: nil)?.records.filter { $0.timestamp >= cutoff } ?? [] + } + } + } + let initialSnapshot: (records: [CLIProxyAPIUsageRecord], needsPruning: Bool) = self.withExclusiveAccess { guard let existingCache = self.loadCache( cacheRoot: cacheRoot, - legacyCacheRoot: legacyCacheRoot) + legacyCacheRoot: nil) else { return ([], false) } let retainedRecords = existingCache.records.filter { $0.timestamp >= cutoff } return (retainedRecords, retainedRecords.count != existingCache.records.count) @@ -175,7 +205,7 @@ enum CLIProxyAPIUsageCacheIO { self.withExclusiveAccess { guard let currentCache = self.loadCache( cacheRoot: cacheRoot, - legacyCacheRoot: legacyCacheRoot) + legacyCacheRoot: nil) else { return [] } let retainedRecords = currentCache.records.filter { $0.timestamp >= cutoff } let retainedCache = Cache(records: retainedRecords) @@ -304,6 +334,18 @@ enum CLIProxyAPIUsageCacheIO { .appendingPathComponent("CodexBar", isDirectory: true) } + private static func hasLegacyCacheToMigrate( + cacheRoot: URL?, + legacyCacheRoot: URL?, + fileManager: FileManager = .default) -> Bool + { + guard let legacyCacheRoot else { return false } + let durableURL = self.cacheFileURL(cacheRoot: cacheRoot) + let legacyURL = self.legacyCacheFileURL(cacheRoot: legacyCacheRoot) + return legacyURL.standardizedFileURL != durableURL.standardizedFileURL + && fileManager.fileExists(atPath: legacyURL.path) + } + private static func loadCache(cacheRoot: URL?, legacyCacheRoot: URL?) -> Cache? { let durableURL = self.cacheFileURL(cacheRoot: cacheRoot) let durableCache: Cache? diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index 2fef17f2fa..34537218a9 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -263,7 +263,7 @@ struct CLIProxyAPIUsageCacheTests { } @Test - func `migration and collection share exclusive cache access`() async throws { + func `legacy migration waits for the collector interprocess lock`() async throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory .appendingPathComponent("cliproxy-usage-lock-\(UUID().uuidString)", isDirectory: true) @@ -284,12 +284,15 @@ struct CLIProxyAPIUsageCacheTests { let releaseLock = DispatchSemaphore(value: 0) let loadStarted = DispatchSemaphore(value: 0) let loadFinished = DispatchSemaphore(value: 0) - let mergeStarted = DispatchSemaphore(value: 0) - let mergeFinished = DispatchSemaphore(value: 0) let lockHolder = Task.detached { - CLIProxyAPIUsageCacheIO.withExclusiveAccess { + try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock(stateRoot: root) { lockAcquired.signal() releaseLock.wait() + #expect(CLIProxyAPIUsageCacheIO.merge( + [collectedRecord], + cacheRoot: durableRoot, + legacyCacheRoot: nil, + now: timestamp) == 1) } } #expect(await Self.waitForSignal(lockAcquired, timeout: .now() + 1)) @@ -303,31 +306,15 @@ struct CLIProxyAPIUsageCacheTests { loadFinished.signal() return records } - let mergeTask = Task.detached { - mergeStarted.signal() - let result = CLIProxyAPIUsageCacheIO.merge( - [collectedRecord], - cacheRoot: durableRoot, - legacyCacheRoot: legacyRoot, - now: timestamp) - mergeFinished.signal() - return result - } #expect(await Self.waitForSignal(loadStarted, timeout: .now() + 1)) - #expect(await Self.waitForSignal(mergeStarted, timeout: .now() + 1)) let loadFinishedBeforeRelease = await Self.waitForSignal( loadFinished, timeout: .now() + .milliseconds(50)) - let mergeFinishedBeforeRelease = await Self.waitForSignal( - mergeFinished, - timeout: .now() + .milliseconds(50)) #expect(!loadFinishedBeforeRelease) - #expect(!mergeFinishedBeforeRelease) releaseLock.signal() - await lockHolder.value - _ = await loadTask.value - #expect(await mergeTask.value == 1) + try await lockHolder.value + #expect(await Set(loadTask.value.map(\.requestID)) == ["legacy", "collected"]) let finalRecords = CLIProxyAPIUsageCacheIO.load( cacheRoot: durableRoot, legacyCacheRoot: legacyRoot, From fdf7763251527b84b98343bd6f4601db6921d631 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Thu, 30 Jul 2026 05:11:16 -0700 Subject: [PATCH 060/183] Stabilize proxy cache timezone test --- .../CostUsageFetcherCachedProxyTimeZoneTests.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Tests/CodexBarTests/CostUsageFetcherCachedProxyTimeZoneTests.swift b/Tests/CodexBarTests/CostUsageFetcherCachedProxyTimeZoneTests.swift index 0e020f31a6..8762dff282 100644 --- a/Tests/CodexBarTests/CostUsageFetcherCachedProxyTimeZoneTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherCachedProxyTimeZoneTests.swift @@ -70,8 +70,10 @@ struct CostUsageFetcherCachedProxyTimeZoneTests { let claudeCache = CostUsageCacheIO.load(provider: .claude, cacheRoot: env.cacheRoot) #expect(!claudeCache.days.isEmpty) var staleZoneCalendar = options.calendar - staleZoneCalendar.timeZone = try #require(TimeZone( - identifier: options.calendar.timeZone.identifier == "UTC" ? "Asia/Bangkok" : "UTC")) + staleZoneCalendar.timeZone = try #require( + ["UTC", "Asia/Bangkok"] + .compactMap(TimeZone.init(identifier:)) + .first { $0.identifier != options.calendar.timeZone.identifier }) CostUsageCacheIO.save( provider: .claude, cache: claudeCache, From ff8417f2c4a33e2c0505b66cd1bbd38cd3fa9dd1 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Thu, 30 Jul 2026 06:30:57 -0700 Subject: [PATCH 061/183] Tighten proxy attribution boundaries --- .../CLIProxyAPIAttributionResolver.swift | 52 +++++++++++++++---- .../CLIProxyAPIUsageTelemetry.swift | 3 ++ .../CostUsage/CostUsageScanner+Claude.swift | 9 +++- .../CLIProxyAPIAttributionBatchTests.swift | 27 ++++++++++ .../CLIProxyAPIAttributionResolverTests.swift | 6 +-- .../CLIProxyAPIUsageCollectorTests.swift | 47 +++++++++++++++++ ...ageFetcherCachedProxyDisconnectTests.swift | 10 ++-- .../CodexBarTests/CostUsageFetcherTests.swift | 4 +- 8 files changed, 136 insertions(+), 22 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift index 31139fd4d8..6bf93e43b6 100644 --- a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift +++ b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift @@ -109,7 +109,10 @@ struct CLIProxyAPIAttributionResolver: Sendable { sessionID: sessionID, timestampUnixMs: timestampUnixMs, tokens: tokens) - let routeObservation = self.sessionObservation(sessionID: sessionID) + let routeObservation = self.matchingObservation( + model: model, + sessionID: sessionID, + timestampUnixMs: timestampUnixMs) let telemetryObservation = self.matchingObservation( model: model, sessionID: sessionID, @@ -128,7 +131,10 @@ struct CLIProxyAPIAttributionResolver: Sendable { func attributions(for requests: [Request]) -> [CostUsageAttribution] { let prepared = requests.map { request in - let routeObservation = self.sessionObservation(sessionID: request.sessionID) + let routeObservation = self.matchingObservation( + model: request.model, + sessionID: request.sessionID, + timestampUnixMs: request.timestampUnixMs) let telemetryObservation = self.matchingObservation( model: request.model, sessionID: request.sessionID, @@ -145,6 +151,26 @@ struct CLIProxyAPIAttributionResolver: Sendable { telemetryObservation: telemetryObservation, usageRecordMatch: usageRecordMatch) } + var routeCandidatesByObservation: + [ObservationKey: [(index: Int, request: Request, observation: Observation)]] = [:] + for (index, item) in prepared.enumerated() { + guard let observation = item.routeObservation else { continue } + routeCandidatesByObservation[Self.observationKey(observation), default: []].append( + (index: index, request: item.request, observation: observation)) + } + var routeOwnerByObservation: [ObservationKey: Int] = [:] + for (key, candidates) in routeCandidatesByObservation { + if candidates.count == 1, let candidate = candidates.first { + routeOwnerByObservation[key] = candidate.index + } else if let observationTimestamp = candidates.first?.observation.timestamp, + let candidate = Self.uniqueClosest( + candidates, + target: observationTimestamp, + timestamp: { Self.timestamp(for: $0.request) }) + { + routeOwnerByObservation[key] = candidate.index + } + } let matchCounts = Dictionary( grouping: prepared.compactMap(\.usageRecordMatch?.key), by: { $0 }).mapValues(\.count) @@ -155,7 +181,12 @@ struct CLIProxyAPIAttributionResolver: Sendable { return Self.observationKey(observation) }) - return prepared.map { item in + return prepared.enumerated().map { index, item in + let routeObservation = item.routeObservation.flatMap { observation in + routeOwnerByObservation[Self.observationKey(observation)] == index + ? observation + : nil + } let usageRecord = item.usageRecordMatch.flatMap { match -> CLIProxyAPIUsageRecord? in guard matchCounts[match.key] == 1, self.allPlausibleObservationsRepresented( @@ -167,7 +198,7 @@ struct CLIProxyAPIAttributionResolver: Sendable { } return self.attribution( request: item.request, - routeObservation: item.routeObservation, + routeObservation: routeObservation, usageRecord: usageRecord) } } @@ -200,13 +231,6 @@ struct CLIProxyAPIAttributionResolver: Sendable { evidence: evidence.sorted { $0.rawValue < $1.rawValue }) } - private func sessionObservation(sessionID: String?) -> Observation? { - guard let sessionID = sessionID?.trimmingCharacters(in: .whitespacesAndNewlines), - !sessionID.isEmpty - else { return nil } - return self.observationsBySessionID[sessionID]?.first - } - private func matchingObservation( model: String, sessionID: String?, @@ -234,6 +258,12 @@ struct CLIProxyAPIAttributionResolver: Sendable { timestamp: \.timestamp) } + private static func timestamp(for request: Request) -> Date? { + request.timestampUnixMs.map { + Date(timeIntervalSince1970: Double($0) / 1000) + } + } + private func matchingUsageRecord( observation: Observation, model: String, diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 1d82f6e389..cd96dca3e7 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -659,6 +659,7 @@ public enum CLIProxyAPIUsageCollector { return await self.collectUnserialized( cacheRoot: cacheRoot, pendingRoot: pendingRoot, + configurationIsCurrent: configurationIsCurrent, shouldContinue: shouldContinue, client: client) } @@ -671,6 +672,7 @@ public enum CLIProxyAPIUsageCollector { private static func collectUnserialized( cacheRoot: URL?, pendingRoot: URL?, + configurationIsCurrent: @escaping @Sendable () -> Bool, shouldContinue: @escaping @Sendable () async -> Bool, client: CLIProxyAPIUsageQueueClient) async -> CLIProxyAPIUsageCollectionResult { @@ -694,6 +696,7 @@ public enum CLIProxyAPIUsageCollector { } for _ in 0.. Bool diff --git a/Tests/CodexBarTests/CostUsageFetcherCachedProxyDisconnectTests.swift b/Tests/CodexBarTests/CostUsageFetcherCachedProxyDisconnectTests.swift index fe7d4356fa..24cbfa45e1 100644 --- a/Tests/CodexBarTests/CostUsageFetcherCachedProxyDisconnectTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherCachedProxyDisconnectTests.swift @@ -3,8 +3,8 @@ import Testing @testable import CodexBarCore struct CostUsageFetcherCachedProxyDisconnectTests { - @Test - func `disconnect strips surviving cached proxy attribution`() async throws { + @Test(arguments: ["claude-sonnet-4-6", "gpt-5.5"]) + func `disconnect strips surviving cached proxy attribution`(model: String) async throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } @@ -18,7 +18,7 @@ struct CostUsageFetcherCachedProxyDisconnectTests { "requestId": "request-proxy", "message": [ "id": "message-proxy", - "model": "claude-sonnet-4-6", + "model": model, "usage": ["input_tokens": 100, "output_tokens": 5], ], ]])) @@ -35,7 +35,7 @@ struct CostUsageFetcherCachedProxyDisconnectTests { === HEADERS === X-Claude-Code-Session-Id: session-proxy === REQUEST BODY === - {"model":"claude-sonnet-4-6"} + {"model":"\(model)"} === API RESPONSE === """ try Data(proxyLog.utf8).write(to: proxyLogs.appendingPathComponent("request.log")) @@ -46,7 +46,7 @@ struct CostUsageFetcherCachedProxyDisconnectTests { provider: "codex", executorType: "CodexExecutor", model: "gpt-5.5", - alias: "claude-sonnet-4-6", + alias: model, endpoint: "/v1/messages", authType: "oauth", requestID: "proxy-request", diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index a20e69d416..e4704fa0b7 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -1139,7 +1139,7 @@ extension CostUsageFetcherTests { } @Test - func `cliproxy codex inventory keeps a session stable beyond the request log window`() async throws { + func `cliproxy request log does not cover a distant resumed turn`() async throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } @@ -1199,7 +1199,7 @@ extension CostUsageFetcherTests { scannerOptions: options) let breakdown = try #require(codex.daily.first?.modelBreakdowns?.first) - #expect(codex.daily.first?.totalTokens == 210) + #expect(codex.daily.first?.totalTokens == 105) #expect(codex.daily.first?.modelBreakdowns?.count == 1) #expect(breakdown.modelName == "gpt-5.5") #expect(breakdown.attribution?.route == .cliProxyAPI) From 53ff713ae1478980e379ee1190efe4c5a7c900cd Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Thu, 30 Jul 2026 06:50:23 -0700 Subject: [PATCH 062/183] Drain in-flight proxy telemetry pops --- .../CLIProxyAPIUsageTelemetry.swift | 4 +- .../CLIProxyAPIUsageCollectorTests.swift | 49 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index cd96dca3e7..be7a958479 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -698,7 +698,9 @@ public enum CLIProxyAPIUsageCollector { for _ in 0.. Bool From 008e9e6cf6fcc6646e3281d703e885ba4edc3f53 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Thu, 30 Jul 2026 07:17:50 -0700 Subject: [PATCH 063/183] Honor live proxy attribution results --- .../CLIProxyAPIAttributionResolver.swift | 7 + .../CostUsage/CostUsageScanner+Claude.swift | 121 ++++++++++++++++-- .../CodexBarTests/CostUsageFetcherTests.swift | 1 + 3 files changed, 116 insertions(+), 13 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift index 6bf93e43b6..a4bd6fcfbf 100644 --- a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift +++ b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift @@ -203,6 +203,13 @@ struct CLIProxyAPIAttributionResolver: Sendable { } } + func hasMatchingObservation(for request: Request) -> Bool { + self.matchingObservation( + model: request.model, + sessionID: request.sessionID, + timestampUnixMs: request.timestampUnixMs) != nil + } + private func attribution( request: Request, routeObservation: Observation?, diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index c4a845a8fe..acd304baf0 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -450,6 +450,85 @@ extension CostUsageScanner { return rows } + private static func reconcileClaudeAttributions( + cache: inout CostUsageCache, + attributionResolver: CLIProxyAPIAttributionResolver, + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) + { + let items = Self.reconciledClaudeRows(cache: cache).compactMap { row + -> ClaudeAttributionReconciliationItem? in + guard let key = Self.claudeCanonicalRowKey(row) else { return nil } + let modelProvider = if let cachedProvider = row.attribution?.modelProvider, + cachedProvider != .unknown + { + cachedProvider + } else { + CostUsagePricing.modelProvider( + for: row.model, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + } + return ClaudeAttributionReconciliationItem( + key: key, + request: CLIProxyAPIAttributionResolver.Request( + model: row.model, + modelProvider: modelProvider, + sessionID: row.sessionId, + timestampUnixMs: row.timestampUnixMs, + tokens: .init( + input: row.input, + cacheRead: row.cacheRead, + cacheCreate: row.cacheCreate, + output: row.output)), + modelProvider: modelProvider) + } + let requests = items.map(\.request) + let liveAttributions = attributionResolver.attributions(for: requests) + var replacementKeys: Set = [] + var replacements: [String: CostUsageAttribution] = [:] + for (index, item) in items.enumerated() + where attributionResolver.hasMatchingObservation(for: item.request) + { + replacementKeys.insert(item.key) + let liveAttribution = liveAttributions[index] + let replacement: CostUsageAttribution? = if liveAttribution.route == .cliProxyAPI { + liveAttribution + } else if item.modelProvider != .anthropic { + liveAttribution + } else { + nil + } + replacements[item.key] = replacement + } + guard !replacementKeys.isEmpty else { return } + + for path in cache.files.keys { + guard var file = cache.files[path], let rows = file.claudeRows else { continue } + file.claudeRows = rows.map { row in + guard let key = Self.claudeCanonicalRowKey(row), replacementKeys.contains(key) else { return row } + return ClaudeUsageRow( + dayKey: row.dayKey, + model: row.model, + sessionId: row.sessionId, + messageId: row.messageId, + requestId: row.requestId, + timestampUnixMs: row.timestampUnixMs, + isSidechain: row.isSidechain, + pathRole: row.pathRole, + input: row.input, + cacheRead: row.cacheRead, + cacheCreate: row.cacheCreate, + cacheCreate1h: row.cacheCreate1h, + output: row.output, + costNanos: row.costNanos, + costPriced: row.costPriced, + attribution: replacements[key]) + } + cache.files[path] = file + } + } + private static func rebuildClaudeDays(cache: inout CostUsageCache) { var days: [String: [String: [Int]]] = [:] @@ -835,6 +914,13 @@ extension CostUsageScanner { cache.files.removeValue(forKey: key) } + if let attributionResolver { + Self.reconcileClaudeAttributions( + cache: &cache, + attributionResolver: attributionResolver, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: options.cacheRoot) + } Self.rebuildClaudeDays(cache: &cache) Self.pruneDays(cache: &cache, sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) cache.scanSinceKey = range.scanSinceKey @@ -864,6 +950,12 @@ extension CostUsageScanner { var repricedCosts: [ClaudeDayModelKey: ClaudeRepricedCost] = [:] } + private struct ClaudeAttributionReconciliationItem { + let key: String + let request: CLIProxyAPIAttributionResolver.Request + let modelProvider: CostUsageAttribution.ModelProvider + } + private struct ClaudeAttributionAggregationContext { let filter: ClaudeAttributionFilter let resolver: CLIProxyAPIAttributionResolver? @@ -890,19 +982,20 @@ extension CostUsageScanner { } return (row: row, modelProvider: modelProvider) } + let requests = rowsWithProviders.map { item in + CLIProxyAPIAttributionResolver.Request( + model: item.row.model, + modelProvider: item.modelProvider, + sessionID: item.row.sessionId, + timestampUnixMs: item.row.timestampUnixMs, + tokens: .init( + input: item.row.input, + cacheRead: item.row.cacheRead, + cacheCreate: item.row.cacheCreate, + output: item.row.output)) + } let liveAttributions: [CostUsageAttribution?] = if let attributionResolver = attributionContext.resolver { - attributionResolver.attributions(for: rowsWithProviders.map { item in - CLIProxyAPIAttributionResolver.Request( - model: item.row.model, - modelProvider: item.modelProvider, - sessionID: item.row.sessionId, - timestampUnixMs: item.row.timestampUnixMs, - tokens: .init( - input: item.row.input, - cacheRead: item.row.cacheRead, - cacheCreate: item.row.cacheCreate, - output: item.row.output)) - }).map(Optional.some) + attributionResolver.attributions(for: requests).map(Optional.some) } else { Array(repeating: nil, count: rowsWithProviders.count) } @@ -910,6 +1003,7 @@ extension CostUsageScanner { for (index, item) in rowsWithProviders.enumerated() { let row = item.row let modelProvider = item.modelProvider + let request = requests[index] let liveAttribution = liveAttributions[index] let cachedAttribution: CostUsageAttribution? = if !attributionContext.allowCachedCLIProxyAPIAttribution, @@ -921,7 +1015,8 @@ extension CostUsageScanner { let attribution: CostUsageAttribution? = if liveAttribution?.route == .cliProxyAPI { liveAttribution } else if attributionContext.allowCachedCLIProxyAPIAttribution, - row.attribution?.route == .cliProxyAPI + row.attribution?.route == .cliProxyAPI, + attributionContext.resolver?.hasMatchingObservation(for: request) != true { row.attribution } else if modelProvider != .anthropic { diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index e4704fa0b7..f4166182d8 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -734,6 +734,7 @@ extension CostUsageFetcherTests { let nativeClaudeAssistant: [String: Any] = [ "type": "assistant", "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "sessionId": "session-proxy", "requestId": "request-claude", "message": [ "id": "message-claude", From 202ffeee5b0815b4fd7e1f294ca4c47501d75e4e Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Thu, 30 Jul 2026 07:38:14 -0700 Subject: [PATCH 064/183] Exclude proxy attribution from share providers --- Sources/CodexBar/ShareStatsPayload.swift | 5 +++-- Tests/CodexBarTests/ShareStatsTests.swift | 27 ++++++++++++++++++++++- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBar/ShareStatsPayload.swift b/Sources/CodexBar/ShareStatsPayload.swift index 744f41ee17..630d32cd36 100644 --- a/Sources/CodexBar/ShareStatsPayload.swift +++ b/Sources/CodexBar/ShareStatsPayload.swift @@ -318,8 +318,9 @@ enum ShareStatsBuilder { subscriptionNames: [String: ShareStatsSubscriptionName] = [:]) -> ShareStatsPayload? { let providers = model.groups.flatMap { group in - group.providers.map { row in - ShareStatsProviderPayload( + group.providers.compactMap { row -> ShareStatsProviderPayload? in + guard row.id != SpendDashboardSource.codexProxySourceID else { return nil } + return ShareStatsProviderPayload( provider: row.provider, providerName: row.displayName, subscriptionName: subscriptionNames[row.id]?.displayName, diff --git a/Tests/CodexBarTests/ShareStatsTests.swift b/Tests/CodexBarTests/ShareStatsTests.swift index 91dc8c24e7..1fe1742d7b 100644 --- a/Tests/CodexBarTests/ShareStatsTests.swift +++ b/Tests/CodexBarTests/ShareStatsTests.swift @@ -19,7 +19,7 @@ struct ShareStatsTests { } @Test - func `proxy spend is not counted as an account or subscription`() { + func `proxy spend is not counted as an account or subscription`() throws { let rows = [ SpendDashboardModel.ProviderRow( id: "codex:managed-account", @@ -49,6 +49,31 @@ struct ShareStatsTests { #expect(spendDashboardCodexAccountRowCount(rows) == 1) #expect(spendDashboardSubscriptionCount(rows) == 2) + + let group = SpendDashboardModel.CurrencyGroup( + currencyCode: "USD", + providers: rows, + models: [ + SpendDashboardModel.ModelRow( + rank: 1, + provider: .codex, + providerName: "Codex · CLIProxyAPI", + modelName: "gpt-5.4", + totalTokens: 100, + totalCost: 1), + ], + dailyPoints: [], + totalTokens: 300, + totalCost: 3, + coveredDayCount: 1, + chartDomain: Self.date...Self.date, + modelHistoryCompleteness: .complete) + let payload = try #require(ShareStatsBuilder.make( + model: SpendDashboardModel(requestedDays: 1, groups: [group]))) + + #expect(payload.providers.map(\.providerName) == ["Codex", "Cursor"]) + #expect(payload.currencies.first?.estimatedCost == 3) + #expect(payload.topModels.first?.estimatedCost == 1) } @Test From 851586b06ad44151bdf6df5a90ff70abeb13e804 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Thu, 30 Jul 2026 07:59:30 -0700 Subject: [PATCH 065/183] Harden proxy telemetry cleanup --- .../CLIProxyAPIUsageTelemetry.swift | 17 +++++++- .../CostUsage/CostUsageScanner+Claude.swift | 42 ++++++++++++++++--- .../CLIProxyAPIUsageCacheTests.swift | 17 ++++++++ 3 files changed, 69 insertions(+), 7 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index be7a958479..309d47da99 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -418,14 +418,27 @@ enum CLIProxyAPIUsagePendingIO { var records: [CLIProxyAPIUsageRecord] = [] } - static func load(pendingRoot: URL? = nil) -> [CLIProxyAPIUsageRecord]? { + private static let maximumRecordAge: TimeInterval = 366 * 24 * 60 * 60 + + static func load( + pendingRoot: URL? = nil, + now: Date = Date()) -> [CLIProxyAPIUsageRecord]? + { let url = self.pendingFileURL(pendingRoot: pendingRoot) guard FileManager.default.fileExists(atPath: url.path) else { return [] } guard let data = try? Data(contentsOf: url), let pendingBatch = try? self.decoder.decode(PendingBatch.self, from: data), pendingBatch.version == 1 else { return nil } - return pendingBatch.records + let cutoff = now.addingTimeInterval(-self.maximumRecordAge) + let retainedRecords = pendingBatch.records.filter { $0.timestamp >= cutoff } + if retainedRecords.count != pendingBatch.records.count { + guard retainedRecords.isEmpty + ? self.clear(pendingRoot: pendingRoot) + : self.save(retainedRecords, pendingRoot: pendingRoot) + else { return nil } + } + return retainedRecords } static func save(_ records: [CLIProxyAPIUsageRecord], pendingRoot: URL? = nil) -> Bool { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index acd304baf0..a2129a675b 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -927,11 +927,43 @@ extension CostUsageScanner { cache.scanUntilKey = range.scanUntilKey cache.lastScanUnixMs = nowMs try checkCancellation?() - CostUsageCacheIO.save( - provider: provider, - cache: cache, - cacheRoot: options.cacheRoot, - calendar: range.calendar) + try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( + stateRoot: options.cacheRoot?.deletingLastPathComponent()) + { + if CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( + stateRoot: options.cacheRoot) + { + for path in cache.files.keys { + guard var file = cache.files[path], let rows = file.claudeRows else { continue } + file.claudeRows = rows.map { row in + guard row.attribution?.route == .cliProxyAPI else { return row } + return ClaudeUsageRow( + dayKey: row.dayKey, + model: row.model, + sessionId: row.sessionId, + messageId: row.messageId, + requestId: row.requestId, + timestampUnixMs: row.timestampUnixMs, + isSidechain: row.isSidechain, + pathRole: row.pathRole, + input: row.input, + cacheRead: row.cacheRead, + cacheCreate: row.cacheCreate, + cacheCreate1h: row.cacheCreate1h, + output: row.output, + costNanos: row.costNanos, + costPriced: row.costPriced, + attribution: nil) + } + cache.files[path] = file + } + } + CostUsageCacheIO.save( + provider: provider, + cache: cache, + cacheRoot: options.cacheRoot, + calendar: range.calendar) + } } let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: options.cacheRoot) diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index 34537218a9..4d1d9abb67 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -396,6 +396,23 @@ struct CLIProxyAPIUsageCacheTests { == records.map { Int64($0.timestamp.timeIntervalSince1970 * 1000) }) } + @Test + func `pending journal prunes expired records without collection`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-pending-retention-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: root) } + let now = try #require(CostUsageDateParser.parse("2026-07-30T12:00:00Z")) + let records = [ + Self.record(id: "expired", timestamp: now.addingTimeInterval(-367 * 24 * 60 * 60)), + Self.record(id: "retained", timestamp: now.addingTimeInterval(-365 * 24 * 60 * 60)), + ] + + #expect(CLIProxyAPIUsagePendingIO.save(records, pendingRoot: root)) + #expect(CLIProxyAPIUsagePendingIO.load(pendingRoot: root, now: now)?.map(\.requestID) == ["retained"]) + #expect(CLIProxyAPIUsagePendingIO.load(pendingRoot: root, now: now)?.map(\.requestID) == ["retained"]) + } + private static func record(id: String, timestamp: Date) -> CLIProxyAPIUsageRecord { CLIProxyAPIUsageRecord( timestamp: timestamp, From 92b6cddd42a8bb2a0ba7ecdc8bc03f55848fed55 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Thu, 30 Jul 2026 15:58:27 -0700 Subject: [PATCH 066/183] Recheck proxy disconnect before reporting --- .../CostUsage/CostUsageScanner+Claude.swift | 16 +++- ...ageFetcherCachedProxyDisconnectTests.swift | 87 +++++++++++++++++++ 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index a2129a675b..844aae8c10 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -967,12 +967,22 @@ extension CostUsageScanner { } let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: options.cacheRoot) + let reportAttributionEnabled = !CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( + stateRoot: options.cacheRoot) + let reportAttributionFilter: ClaudeAttributionFilter = if reportAttributionEnabled { + options.claudeAttributionFilter + } else { + switch options.claudeAttributionFilter { + case .all, .excludeCodexBackend: .all + case .codexBackendOnly: .codexBackendOnly + } + } return Self.buildClaudeReportFromCache( cache: cache, range: range, - attributionFilter: cliProxyAPIAttributionEnabled ? options.claudeAttributionFilter : .all, - attributionResolver: attributionResolver, - allowCachedCLIProxyAPIAttribution: cliProxyAPIAttributionEnabled, + attributionFilter: reportAttributionFilter, + attributionResolver: reportAttributionEnabled ? attributionResolver : nil, + allowCachedCLIProxyAPIAttribution: reportAttributionEnabled, modelsDevCatalog: modelsDevCatalog, modelsDevCacheRoot: options.cacheRoot) } diff --git a/Tests/CodexBarTests/CostUsageFetcherCachedProxyDisconnectTests.swift b/Tests/CodexBarTests/CostUsageFetcherCachedProxyDisconnectTests.swift index 24cbfa45e1..0bbe65a172 100644 --- a/Tests/CodexBarTests/CostUsageFetcherCachedProxyDisconnectTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherCachedProxyDisconnectTests.swift @@ -3,6 +3,93 @@ import Testing @testable import CodexBarCore struct CostUsageFetcherCachedProxyDisconnectTests { + @Test + func `disconnect during proxy scan excludes the stale Codex report`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 24) + _ = try env.writeClaudeProjectFile( + relativePath: "proxy-race/session.jsonl", + contents: env.jsonl([[ + "type": "assistant", + "timestamp": env.isoString(for: day), + "sessionId": "session-proxy-race", + "requestId": "request-proxy-race", + "message": [ + "id": "message-proxy-race", + "model": "claude-sonnet-4-6", + "usage": ["input_tokens": 100, "output_tokens": 5], + ], + ]])) + + let proxyHome = env.root.appendingPathComponent("cli-proxy-api", isDirectory: true) + let proxyLogs = proxyHome.appendingPathComponent("logs", isDirectory: true) + try FileManager.default.createDirectory(at: proxyLogs, withIntermediateDirectories: true) + try Data(#"{"type":"codex"}"#.utf8) + .write(to: proxyHome.appendingPathComponent("codex-auth.json")) + let proxyLog = """ + === REQUEST INFO === + URL: /v1/messages + Timestamp: \(env.isoString(for: day)) + === HEADERS === + X-Claude-Code-Session-Id: session-proxy-race + === REQUEST BODY === + {"model":"claude-sonnet-4-6"} + === API RESPONSE === + """ + try Data(proxyLog.utf8).write(to: proxyLogs.appendingPathComponent("request.log")) + #expect(CLIProxyAPIUsageCacheIO.merge( + [ + CLIProxyAPIUsageRecord( + timestamp: day, + provider: "codex", + executorType: "CodexExecutor", + model: "gpt-5.5", + alias: "claude-sonnet-4-6", + endpoint: "/v1/messages", + authType: "oauth", + requestID: "request-proxy-race", + tokens: .init(input: 100, output: 5, total: 105)), + ], + cacheRoot: env.cacheRoot, + now: day) == 1) + + var options = CostUsageScanner.Options( + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot, + claudeAttributionFilter: .codexBackendOnly, + cliProxyAPIHome: proxyHome) + options.forceRescan = true + var didDisconnect = false + let report = try CostUsageScanner.loadClaudeDaily( + provider: .claude, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + now: day, + options: options, + checkCancellation: { + guard !didDisconnect else { return } + didDisconnect = true + #expect(CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected( + true, + stateRoot: env.cacheRoot)) + }) + + #expect(didDisconnect) + #expect(report.data.isEmpty) + + options.forceRescan = false + options.claudeAttributionFilter = .excludeCodexBackend + let claudeReport = try CostUsageScanner.loadClaudeDaily( + provider: .claude, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + now: day, + options: options, + checkCancellation: nil) + #expect(claudeReport.data.first?.totalTokens == 105) + #expect(claudeReport.data.first?.modelBreakdowns?.first?.attribution == nil) + } + @Test(arguments: ["claude-sonnet-4-6", "gpt-5.5"]) func `disconnect strips surviving cached proxy attribution`(model: String) async throws { let env = try CostUsageTestEnvironment() From ae3aaf1f1ef90cb22b0ce57d159872d05f0a5264 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Thu, 30 Jul 2026 16:24:13 -0700 Subject: [PATCH 067/183] Reconcile unkeyed proxy attribution --- .../CostUsage/CostUsageScanner+Claude.swift | 54 ++++++++++-- ...IProxyAttributionReconciliationTests.swift | 85 +++++++++++++++++++ 2 files changed, 130 insertions(+), 9 deletions(-) create mode 100644 Tests/CodexBarTests/CostUsageFetcherCLIProxyAttributionReconciliationTests.swift diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 844aae8c10..d3107a0a74 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -450,15 +450,44 @@ extension CostUsageScanner { return rows } + private static func claudeAttributionReconciliationRows( + cache: CostUsageCache) -> [(key: ClaudeAttributionReconciliationKey, row: ClaudeUsageRow)] + { + var rows: [(key: ClaudeAttributionReconciliationKey, row: ClaudeUsageRow)] = [] + var winners: [String: (path: String, row: ClaudeUsageRow)] = [:] + + for path in cache.files.keys.sorted() { + guard let fileRows = cache.files[path]?.claudeRows else { continue } + for (index, row) in fileRows.enumerated() { + guard let canonicalKey = Self.claudeCanonicalRowKey(row) else { + rows.append((key: .unkeyed(path: path, index: index), row: row)) + continue + } + let candidate = (path: path, row: row) + if let existing = winners[canonicalKey] { + if Self.claudeRowWins(lhs: candidate, rhs: existing) { + winners[canonicalKey] = candidate + } + } else { + winners[canonicalKey] = candidate + } + } + } + + rows.append(contentsOf: winners.keys.sorted().compactMap { key in + winners[key].map { (key: .canonical(key), row: $0.row) } + }) + return rows + } + private static func reconcileClaudeAttributions( cache: inout CostUsageCache, attributionResolver: CLIProxyAPIAttributionResolver, modelsDevCatalog: ModelsDevCatalog?, modelsDevCacheRoot: URL?) { - let items = Self.reconciledClaudeRows(cache: cache).compactMap { row - -> ClaudeAttributionReconciliationItem? in - guard let key = Self.claudeCanonicalRowKey(row) else { return nil } + let items = Self.claudeAttributionReconciliationRows(cache: cache).map { item in + let row = item.row let modelProvider = if let cachedProvider = row.attribution?.modelProvider, cachedProvider != .unknown { @@ -470,7 +499,7 @@ extension CostUsageScanner { modelsDevCacheRoot: modelsDevCacheRoot) } return ClaudeAttributionReconciliationItem( - key: key, + key: item.key, request: CLIProxyAPIAttributionResolver.Request( model: row.model, modelProvider: modelProvider, @@ -485,8 +514,8 @@ extension CostUsageScanner { } let requests = items.map(\.request) let liveAttributions = attributionResolver.attributions(for: requests) - var replacementKeys: Set = [] - var replacements: [String: CostUsageAttribution] = [:] + var replacementKeys: Set = [] + var replacements: [ClaudeAttributionReconciliationKey: CostUsageAttribution] = [:] for (index, item) in items.enumerated() where attributionResolver.hasMatchingObservation(for: item.request) { @@ -505,8 +534,10 @@ extension CostUsageScanner { for path in cache.files.keys { guard var file = cache.files[path], let rows = file.claudeRows else { continue } - file.claudeRows = rows.map { row in - guard let key = Self.claudeCanonicalRowKey(row), replacementKeys.contains(key) else { return row } + file.claudeRows = rows.enumerated().map { index, row in + let key = Self.claudeCanonicalRowKey(row).map(ClaudeAttributionReconciliationKey.canonical) + ?? .unkeyed(path: path, index: index) + guard replacementKeys.contains(key) else { return row } return ClaudeUsageRow( dayKey: row.dayKey, model: row.model, @@ -992,8 +1023,13 @@ extension CostUsageScanner { var repricedCosts: [ClaudeDayModelKey: ClaudeRepricedCost] = [:] } + private enum ClaudeAttributionReconciliationKey: Hashable { + case canonical(String) + case unkeyed(path: String, index: Int) + } + private struct ClaudeAttributionReconciliationItem { - let key: String + let key: ClaudeAttributionReconciliationKey let request: CLIProxyAPIAttributionResolver.Request let modelProvider: CostUsageAttribution.ModelProvider } diff --git a/Tests/CodexBarTests/CostUsageFetcherCLIProxyAttributionReconciliationTests.swift b/Tests/CodexBarTests/CostUsageFetcherCLIProxyAttributionReconciliationTests.swift new file mode 100644 index 0000000000..aeb511b67e --- /dev/null +++ b/Tests/CodexBarTests/CostUsageFetcherCLIProxyAttributionReconciliationTests.swift @@ -0,0 +1,85 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct CostUsageFetcherCLIProxyAttributionReconciliationTests { + @Test + func `batch reconciliation includes unkeyed legacy Claude rows`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 24) + func assistant(seconds: TimeInterval) -> [String: Any] { + [ + "type": "assistant", + "timestamp": env.isoString(for: day.addingTimeInterval(seconds)), + "sessionId": "legacy-session", + "message": [ + "model": "gpt-5.5", + "usage": ["input_tokens": 100, "output_tokens": 5], + ], + ] + } + _ = try env.writeClaudeProjectFile( + relativePath: "legacy-proxy/session.jsonl", + contents: env.jsonl([ + assistant(seconds: 0), + assistant(seconds: 30), + ])) + + let cliProxyHome = env.root.appendingPathComponent("cli-proxy-api", isDirectory: true) + let cliProxyLogs = cliProxyHome.appendingPathComponent("logs", isDirectory: true) + try FileManager.default.createDirectory(at: cliProxyLogs, withIntermediateDirectories: true) + try Data(#"{"type":"codex"}"#.utf8) + .write(to: cliProxyHome.appendingPathComponent("codex-auth.json")) + let proxyLog = """ + === REQUEST INFO === + URL: /v1/messages + Timestamp: \(env.isoString(for: day)) + === HEADERS === + X-Claude-Code-Session-Id: legacy-session + === REQUEST BODY === + {"model":"gpt-5.5"} + === API RESPONSE === + """ + try Data(proxyLog.utf8).write(to: cliProxyLogs.appendingPathComponent("request.log")) + #expect(CLIProxyAPIUsageCacheIO.merge( + [ + CLIProxyAPIUsageRecord( + timestamp: day, + provider: "codex", + executorType: "CodexExecutor", + model: "gpt-5.5", + alias: "gpt-5.5", + endpoint: "/v1/messages", + authType: "oauth", + requestID: "legacy-proxy-request", + tokens: .init(input: 100, output: 5, total: 105)), + ], + cacheRoot: env.cacheRoot, + now: day) == 1) + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot, + cliProxyAPIHome: cliProxyHome) + + let codex = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + allowPricingRefresh: false, + includePiSessions: false, + scannerOptions: options) + let claude = try await CostUsageFetcher.loadTokenSnapshot( + provider: .claude, + now: day, + allowPricingRefresh: false, + includePiSessions: false, + scannerOptions: options) + + #expect(codex.daily.first?.totalTokens == 105) + #expect(codex.daily.first?.modelBreakdowns?.first?.attribution?.route == .cliProxyAPI) + #expect(claude.daily.isEmpty) + } +} From 3bae2749509eff3f5f3e0e561de54cb297248fb4 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sun, 2 Aug 2026 11:58:03 -0700 Subject: [PATCH 068/183] Recognize CLIProxyAPI Codex model aliases --- CHANGELOG.md | 3 + .../CLIProxyAPIAttributionResolver.swift | 134 ++++++++++++++-- Sources/CodexBarCore/CostUsageFetcher.swift | 6 + Sources/CodexBarCore/CostUsageModels.swift | 1 + .../CostUsage/CostUsageScanner+Claude.swift | 30 +++- .../CLIProxyAPIAliasRegressionTests.swift | 147 ++++++++++++++++++ 6 files changed, 307 insertions(+), 14 deletions(-) create mode 100644 Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 04ff90fbe3..da8f673ac8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ ### Changed - About: link the Website entry to codex.bar. +### Fixed +- Codex: attribute both configured CLIProxyAPI Codex OAuth base models and their aliases to proxy-routed usage. + ## 0.46.0 — 2026-07-29 ### Added diff --git a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift index a4bd6fcfbf..7411b14302 100644 --- a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift +++ b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift @@ -58,12 +58,14 @@ struct CLIProxyAPIAttributionResolver: Sendable { private let observationsByCanonicalModel: [String: [Observation]] private let usageRecordsByCanonicalModel: [String: [IndexedUsageRecord]] private let authProviders: [AuthProvider] + private let codexOAuthModelRoutes: [String: String] private let hasConfiguredOpenAIAPIUpstream: Bool init( observations: [Observation], usageRecords: [CLIProxyAPIUsageRecord] = [], authProviders: [AuthProvider] = [], + codexOAuthModelAliases: [String: String] = [:], hasConfiguredOpenAIAPIUpstream: Bool = false) { self.observationsBySessionID = Dictionary(grouping: observations, by: \.sessionID) @@ -72,6 +74,12 @@ struct CLIProxyAPIAttributionResolver: Sendable { by: { Self.canonicalModel($0.model) }) self.usageRecordsByCanonicalModel = Self.indexUsageRecords(usageRecords) self.authProviders = authProviders + self.codexOAuthModelRoutes = codexOAuthModelAliases.reduce(into: [:]) { result, entry in + let upstreamModel = entry.value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !upstreamModel.isEmpty else { return } + result[Self.canonicalModel(entry.key)] = upstreamModel + result[Self.canonicalModel(upstreamModel)] = upstreamModel + } self.hasConfiguredOpenAIAPIUpstream = hasConfiguredOpenAIAPIUpstream } @@ -91,6 +99,7 @@ struct CLIProxyAPIAttributionResolver: Sendable { observations: observations, usageRecords: CLIProxyAPIUsageCacheIO.load(cacheRoot: cacheRoot), authProviders: self.loadAuthProviders(home: home, fileManager: fileManager), + codexOAuthModelAliases: self.loadCodexOAuthModelAliases(home: home, fileManager: fileManager), hasConfiguredOpenAIAPIUpstream: self.hasConfiguredOpenAIAPIUpstream( home: home, fileManager: fileManager)) @@ -215,14 +224,25 @@ struct CLIProxyAPIAttributionResolver: Sendable { routeObservation: Observation?, usageRecord: CLIProxyAPIUsageRecord?) -> CostUsageAttribution { - let inventoryUpstream = routeObservation != nil && usageRecord == nil - ? self.authInventoryUpstream(model: request.model, modelProvider: request.modelProvider) + let canonicalModel = Self.canonicalModel(request.model) + let configuredCodexModel = self.codexOAuthModelRoutes[canonicalModel] + let resolvedModelProvider: CostUsageAttribution.ModelProvider = + request.modelProvider == .unknown && configuredCodexModel != nil ? .openAI : request.modelProvider + let inventoryUpstream = usageRecord == nil + ? self.authInventoryUpstream( + model: request.model, + modelProvider: resolvedModelProvider, + routeObserved: routeObservation != nil, + configuredCodexModel: configuredCodexModel) : nil let routeConfirmed = routeObservation != nil || inventoryUpstream != nil var evidence: Set = [.modelProvider] - if routeObservation != nil || inventoryUpstream != nil { + if routeObservation != nil { evidence.insert(.cliProxyRequestLog) } + if configuredCodexModel != nil, inventoryUpstream != nil { + evidence.insert(.cliProxyModelAlias) + } if usageRecord != nil { evidence.insert(.cliProxyUsageTelemetry) } @@ -233,7 +253,7 @@ struct CLIProxyAPIAttributionResolver: Sendable { return CostUsageAttribution( client: .claudeCode, route: routeConfirmed ? .cliProxyAPI : .unknown, - modelProvider: request.modelProvider, + modelProvider: resolvedModelProvider, upstream: usageRecord.map(Self.upstream) ?? inventoryUpstream, evidence: evidence.sorted { $0.rawValue < $1.rawValue }) } @@ -395,14 +415,27 @@ struct CLIProxyAPIAttributionResolver: Sendable { private func authInventoryUpstream( model: String, - modelProvider: CostUsageAttribution.ModelProvider) -> CostUsageAttribution.Upstream? + modelProvider: CostUsageAttribution.ModelProvider, + routeObserved: Bool, + configuredCodexModel: String?) -> CostUsageAttribution.Upstream? { - guard modelProvider == .openAI, - !self.observationsBySessionID.isEmpty, - !self.hasConfiguredOpenAIAPIUpstream - else { return nil } let providers = Array(Set(self.authProviders)) - guard providers.count == 1, + let codexProviders = providers.filter { + $0.provider.caseInsensitiveCompare("codex") == .orderedSame + } + if let configuredCodexModel { + guard codexProviders.count == 1, let provider = codexProviders.first else { return nil } + return CostUsageAttribution.Upstream( + provider: provider.provider, + authType: provider.authType, + model: configuredCodexModel) + } + + guard routeObserved, + modelProvider == .openAI, + !self.observationsBySessionID.isEmpty, + !self.hasConfiguredOpenAIAPIUpstream, + providers.count == 1, let provider = providers.first, provider.provider.caseInsensitiveCompare("codex") == .orderedSame else { return nil } @@ -571,6 +604,87 @@ struct CLIProxyAPIAttributionResolver: Sendable { return Array(Set(providers)) } + static func parseCodexOAuthModelAliases(_ text: String) -> [String: String] { + var aliases: [String: String] = [:] + var rootIndent: Int? + var codexIndent: Int? + var currentName: String? + + for rawLine in text.split(whereSeparator: \.isNewline) { + let line = String(rawLine) + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty, !trimmed.hasPrefix("#") else { continue } + let indent = line.prefix { $0 == " " }.count + + if rootIndent == nil { + if trimmed == "oauth-model-alias:" { + rootIndent = indent + } + continue + } + + guard let rootIndent else { continue } + if indent <= rootIndent { + break + } + if codexIndent == nil { + if trimmed == "codex:" { + codexIndent = indent + } + continue + } + + guard let codexIndent else { continue } + if indent <= codexIndent { + break + } + if trimmed.hasPrefix("- name:") { + currentName = self.simpleYAMLScalar(String(trimmed.dropFirst("- name:".count))) + } else if trimmed.hasPrefix("alias:"), let currentName { + let alias = self.simpleYAMLScalar(String(trimmed.dropFirst("alias:".count))) + if !alias.isEmpty, !currentName.isEmpty { + aliases[alias] = currentName + } + } + } + return aliases + } + + static func hasCodexOAuthModelAliasRoute( + home: URL, + fileManager: FileManager = .default) -> Bool + { + guard !self.loadCodexOAuthModelAliases(home: home, fileManager: fileManager).isEmpty else { + return false + } + return self.loadAuthProviders(home: home, fileManager: fileManager).contains { + $0.provider.caseInsensitiveCompare("codex") == .orderedSame + } + } + + private static func loadCodexOAuthModelAliases( + home: URL, + fileManager: FileManager) -> [String: String] + { + let url = home.appendingPathComponent("config.yaml", isDirectory: false) + guard fileManager.fileExists(atPath: url.path), + let text = try? String(contentsOf: url, encoding: .utf8) + else { return [:] } + return self.parseCodexOAuthModelAliases(text) + } + + private static func simpleYAMLScalar(_ raw: String) -> String { + let trimmed = raw.trimmingCharacters(in: .whitespaces) + guard let first = trimmed.first else { return "" } + if first == "\"" || first == "'" { + let remainder = trimmed.dropFirst() + guard let end = remainder.firstIndex(of: first) else { return String(remainder) } + return String(remainder[.. Bool diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index e1931af495..8a93a0625a 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -1122,6 +1122,12 @@ extension CostUsageFetcher { } guard let home = options.cliProxyAPIHome else { return false } + if CLIProxyAPIAttributionResolver.hasCodexOAuthModelAliasRoute( + home: home, + fileManager: fileManager) + { + return true + } let logDirectory = home.appendingPathComponent("logs", isDirectory: true) guard let urls = try? fileManager.contentsOfDirectory( at: logDirectory, diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index e807e023ef..e3e8162801 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -351,6 +351,7 @@ public struct CostUsageAttribution: Sendable, Codable, Equatable, Hashable { public enum Evidence: String, Sendable, Codable, Hashable { case cliProxyAuthInventory + case cliProxyModelAlias case modelProvider case cliProxyRequestLog case cliProxyUsageTelemetry diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index d3107a0a74..75be502021 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -510,7 +510,8 @@ extension CostUsageScanner { cacheRead: row.cacheRead, cacheCreate: row.cacheCreate, output: row.output)), - modelProvider: modelProvider) + modelProvider: modelProvider, + cachedAttribution: row.attribution) } let requests = items.map(\.request) let liveAttributions = attributionResolver.attributions(for: requests) @@ -522,7 +523,9 @@ extension CostUsageScanner { replacementKeys.insert(item.key) let liveAttribution = liveAttributions[index] let replacement: CostUsageAttribution? = if liveAttribution.route == .cliProxyAPI { - liveAttribution + Self.preferredCLIProxyAPIAttribution( + live: liveAttribution, + cached: item.cachedAttribution) } else if item.modelProvider != .anthropic { liveAttribution } else { @@ -1032,6 +1035,7 @@ extension CostUsageScanner { let key: ClaudeAttributionReconciliationKey let request: CLIProxyAPIAttributionResolver.Request let modelProvider: CostUsageAttribution.ModelProvider + let cachedAttribution: CostUsageAttribution? } private struct ClaudeAttributionAggregationContext { @@ -1090,8 +1094,12 @@ extension CostUsageScanner { } else { row.attribution } - let attribution: CostUsageAttribution? = if liveAttribution?.route == .cliProxyAPI { - liveAttribution + let attribution: CostUsageAttribution? = if let liveAttribution, + liveAttribution.route == .cliProxyAPI + { + Self.preferredCLIProxyAPIAttribution( + live: liveAttribution, + cached: cachedAttribution) } else if attributionContext.allowCachedCLIProxyAPIAttribution, row.attribution?.route == .cliProxyAPI, attributionContext.resolver?.hasMatchingObservation(for: request) != true @@ -1167,6 +1175,20 @@ extension CostUsageScanner { return result } + static func preferredCLIProxyAPIAttribution( + live: CostUsageAttribution, + cached: CostUsageAttribution?) -> CostUsageAttribution + { + guard live.route == .cliProxyAPI, + live.upstream == nil, + let cached, + cached.route == .cliProxyAPI, + cached.upstream != nil, + cached.evidence.contains(.cliProxyUsageTelemetry) + else { return live } + return cached + } + static func resolvedClaudeRowCost( wasPriced: Bool, cachedCostNanos: Int, diff --git a/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift b/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift new file mode 100644 index 0000000000..063e6b4ae5 --- /dev/null +++ b/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift @@ -0,0 +1,147 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CLIProxyAPIAliasRegressionTests { + @Test + func `codex oauth model and alias identify routed usage without request logs`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-alias-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + try Self.writeCodexAliasConfiguration(to: root, fileManager: fileManager) + let resolver = try CLIProxyAPIAttributionResolver.load(home: root, fileManager: fileManager) + for model in ["gpt-5.5", "proxy-codex-alias"] { + let attribution = resolver.attribution( + model: model, + modelProvider: .unknown, + sessionID: nil, + timestampUnixMs: nil, + tokens: Self.tokens) + + #expect(attribution.route == .cliProxyAPI) + #expect(attribution.modelProvider == .openAI) + #expect(attribution.upstream == .init(provider: "codex", authType: .oauth, model: "gpt-5.5")) + #expect(attribution.evidence.contains(.cliProxyAuthInventory)) + #expect(attribution.evidence.contains(.cliProxyModelAlias)) + #expect(!attribution.evidence.contains(.cliProxyRequestLog)) + } + } + + @Test + func `codex oauth alias parser ignores comments and other providers`() { + let configuration = """ + # oauth-model-alias: + # codex: + # - name: "ignored" + oauth-model-alias: + codex: + - name: 'gpt-5.5' + alias: 'proxy-codex-alias' # local alias + vertex: + - name: "gemini-test" + alias: "unrelated-alias" + """ + + #expect(CLIProxyAPIAttributionResolver.parseCodexOAuthModelAliases(configuration) == [ + "proxy-codex-alias": "gpt-5.5", + ]) + } + + @Test + func `weaker live route evidence preserves cached telemetry upstream`() { + let cached = CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .openAI, + upstream: .init(provider: "codex", authType: .oauth, model: "gpt-5.5"), + evidence: [.cliProxyRequestLog, .cliProxyUsageTelemetry, .modelProvider]) + let live = CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .openAI, + evidence: [.cliProxyRequestLog, .modelProvider]) + + #expect(CostUsageScanner.preferredCLIProxyAPIAttribution(live: live, cached: cached) == cached) + } + + @Test + func `codex oauth model and alias restore historical proxy usage without request logs`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 24) + _ = try env.writeClaudeProjectFile( + relativePath: "aliased-proxy/session.jsonl", + contents: env.jsonl((0..<2).map { index in + [ + "type": "assistant", + "timestamp": env.isoString(for: day.addingTimeInterval(TimeInterval(index))), + "sessionId": "aliased-proxy-session", + "requestId": "aliased-request-\(index)", + "message": [ + "id": "aliased-message-\(index)", + "model": index == 0 ? "gpt-5.5" : "proxy-codex-alias", + "usage": ["input_tokens": 100, "output_tokens": 5], + ], + ] + })) + let cliProxyHome = env.root.appendingPathComponent("cli-proxy-api", isDirectory: true) + try FileManager.default.createDirectory(at: cliProxyHome, withIntermediateDirectories: true) + try Self.writeCodexAliasConfiguration(to: cliProxyHome, fileManager: .default) + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot, + cliProxyAPIHome: cliProxyHome) + + let codex = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + allowPricingRefresh: false, + includePiSessions: false, + scannerOptions: options) + let claude = try await CostUsageFetcher.loadTokenSnapshot( + provider: .claude, + now: day, + allowPricingRefresh: false, + includePiSessions: false, + scannerOptions: options) + + #expect(codex.daily.first?.totalTokens == 210) + #expect(codex.daily.first?.modelBreakdowns?.count == 2) + #expect(codex.daily.first?.modelBreakdowns?.allSatisfy { $0.attribution?.upstream?.isCodex == true } == true) + #expect(codex.daily.first?.modelBreakdowns?.allSatisfy { + $0.attribution?.evidence.contains(.cliProxyModelAlias) == true + } == true) + #expect(claude.daily.isEmpty) + } + + private static let tokens = CLIProxyAPIAttributionResolver.TokenSignature( + input: 10, + cacheRead: 30, + cacheCreate: 40, + output: 20) + + private static func writeCodexAliasConfiguration( + to root: URL, + fileManager: FileManager) throws + { + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + let configuration = """ + oauth-model-alias: + codex: + - name: "gpt-5.5" + alias: "proxy-codex-alias" + force-mapping: true + vertex: + - name: "gemini-test" + alias: "unrelated-alias" + """ + try Data(configuration.utf8).write(to: root.appendingPathComponent("config.yaml")) + try Data(#"{"type":"codex","disabled":false}"#.utf8) + .write(to: root.appendingPathComponent("codex-auth.json")) + } +} From d5d885a95cb69e212812fcfe47d7a358cce3e9cc Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sun, 2 Aug 2026 16:04:35 -0700 Subject: [PATCH 069/183] Require route evidence for proxy aliases --- .../CLIProxyAPIAttributionResolver.swift | 10 +++- .../CLIProxyAPIAliasRegressionTests.swift | 49 ++++++++++++++----- 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift index 7411b14302..658b67f199 100644 --- a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift +++ b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift @@ -226,8 +226,11 @@ struct CLIProxyAPIAttributionResolver: Sendable { { let canonicalModel = Self.canonicalModel(request.model) let configuredCodexModel = self.codexOAuthModelRoutes[canonicalModel] + let routeObserved = routeObservation != nil || usageRecord != nil let resolvedModelProvider: CostUsageAttribution.ModelProvider = - request.modelProvider == .unknown && configuredCodexModel != nil ? .openAI : request.modelProvider + request.modelProvider == .unknown && configuredCodexModel != nil && routeObserved + ? .openAI + : request.modelProvider let inventoryUpstream = usageRecord == nil ? self.authInventoryUpstream( model: request.model, @@ -424,7 +427,10 @@ struct CLIProxyAPIAttributionResolver: Sendable { $0.provider.caseInsensitiveCompare("codex") == .orderedSame } if let configuredCodexModel { - guard codexProviders.count == 1, let provider = codexProviders.first else { return nil } + guard routeObserved, + codexProviders.count == 1, + let provider = codexProviders.first + else { return nil } return CostUsageAttribution.Upstream( provider: provider.provider, authType: provider.authType, diff --git a/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift b/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift index 063e6b4ae5..bb16795f38 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift @@ -4,7 +4,7 @@ import Testing struct CLIProxyAPIAliasRegressionTests { @Test - func `codex oauth model and alias identify routed usage without request logs`() throws { + func `codex oauth model and alias do not prove a proxy route`() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory .appendingPathComponent("cliproxy-alias-\(UUID().uuidString)", isDirectory: true) @@ -21,12 +21,40 @@ struct CLIProxyAPIAliasRegressionTests { timestampUnixMs: nil, tokens: Self.tokens) + #expect(attribution.route == .unknown) + #expect(attribution.modelProvider == .unknown) + #expect(attribution.upstream == nil) + #expect(!attribution.evidence.contains(.cliProxyAuthInventory)) + #expect(!attribution.evidence.contains(.cliProxyModelAlias)) + #expect(!attribution.evidence.contains(.cliProxyRequestLog)) + } + } + + @Test + func `codex oauth model and alias resolve after request route evidence`() { + let timestamp = Date(timeIntervalSince1970: 1_784_179_200) + for model in ["gpt-5.5", "proxy-codex-alias"] { + let resolver = CLIProxyAPIAttributionResolver( + observations: [ + .init(sessionID: "proxied-session", model: model, timestamp: timestamp), + ], + authProviders: [ + .init(provider: "codex", authType: .oauth), + ], + codexOAuthModelAliases: ["proxy-codex-alias": "gpt-5.5"]) + let attribution = resolver.attribution( + model: model, + modelProvider: .unknown, + sessionID: "proxied-session", + timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), + tokens: Self.tokens) + #expect(attribution.route == .cliProxyAPI) #expect(attribution.modelProvider == .openAI) #expect(attribution.upstream == .init(provider: "codex", authType: .oauth, model: "gpt-5.5")) #expect(attribution.evidence.contains(.cliProxyAuthInventory)) #expect(attribution.evidence.contains(.cliProxyModelAlias)) - #expect(!attribution.evidence.contains(.cliProxyRequestLog)) + #expect(attribution.evidence.contains(.cliProxyRequestLog)) } } @@ -68,14 +96,14 @@ struct CLIProxyAPIAliasRegressionTests { } @Test - func `codex oauth model and alias restore historical proxy usage without request logs`() async throws { + func `codex oauth alias keeps direct historical usage with Claude`() async throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 7, day: 24) _ = try env.writeClaudeProjectFile( relativePath: "aliased-proxy/session.jsonl", - contents: env.jsonl((0..<2).map { index in + contents: env.jsonl((0..<1).map { index in [ "type": "assistant", "timestamp": env.isoString(for: day.addingTimeInterval(TimeInterval(index))), @@ -83,7 +111,7 @@ struct CLIProxyAPIAliasRegressionTests { "requestId": "aliased-request-\(index)", "message": [ "id": "aliased-message-\(index)", - "model": index == 0 ? "gpt-5.5" : "proxy-codex-alias", + "model": "proxy-codex-alias", "usage": ["input_tokens": 100, "output_tokens": 5], ], ] @@ -110,13 +138,12 @@ struct CLIProxyAPIAliasRegressionTests { includePiSessions: false, scannerOptions: options) - #expect(codex.daily.first?.totalTokens == 210) - #expect(codex.daily.first?.modelBreakdowns?.count == 2) - #expect(codex.daily.first?.modelBreakdowns?.allSatisfy { $0.attribution?.upstream?.isCodex == true } == true) - #expect(codex.daily.first?.modelBreakdowns?.allSatisfy { - $0.attribution?.evidence.contains(.cliProxyModelAlias) == true + #expect(codex.daily.isEmpty) + #expect(claude.daily.first?.totalTokens == 105) + #expect(claude.daily.first?.modelBreakdowns?.count == 1) + #expect(claude.daily.first?.modelBreakdowns?.allSatisfy { + $0.attribution?.route != .cliProxyAPI } == true) - #expect(claude.daily.isEmpty) } private static let tokens = CLIProxyAPIAttributionResolver.TokenSignature( From 1f0fddbde3e6bc7850c55bb5fc4edd270c647865 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sun, 2 Aug 2026 17:00:21 -0700 Subject: [PATCH 070/183] Make proxy removal atomic --- .../PreferencesSpendDashboardPane.swift | 4 +- Sources/CodexBar/UsageStore+CLIProxyAPI.swift | 22 ++--- Sources/CodexBar/UsageStore+TokenCost.swift | 32 ++++++- Sources/CodexBar/UsageStore.swift | 9 +- .../CLIProxyAPIUsageTelemetry.swift | 90 ++++++++++++++++++- .../CostUsageCacheLocations.swift | 2 +- .../CLIProxyAPIUsageCacheTests.swift | 52 +++++++++++ .../CLIProxyAPIUsageStoreTests.swift | 52 ++++++----- .../UsageStoreCoverageTests.swift | 6 +- 9 files changed, 212 insertions(+), 57 deletions(-) diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index 5722cc636e..473f79cc27 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -479,11 +479,13 @@ struct SpendDashboardPane: View { self.cliProxyAPIStatus = "Enter a loopback URL and management key." return } + let collectorTask = self.store.stopCLIProxyAPIUsageCollector() + await collectorTask?.value guard CLIProxyAPIConnectionSettingsStore.save(configuration) else { + self.store.startCLIProxyAPIUsageCollector() self.cliProxyAPIStatus = "Could not save the management key." return } - self.store.stopCLIProxyAPIUsageCollector() self.cliProxyAPIManagementKey = "" self.cliProxyAPIHasSavedConfiguration = true diff --git a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift index 1bd874672b..cb3084c862 100644 --- a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift +++ b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift @@ -1,12 +1,6 @@ import CodexBarCore import Foundation -enum CLIProxyAPIConfigurationRemovalResult: Equatable { - case removed - case configurationRemovalFailed - case telemetryCleanupFailed -} - @MainActor extension UsageStore { private static let cliProxyAPIUsageCollectionInterval: Duration = .seconds(30) @@ -35,22 +29,22 @@ extension UsageStore { @discardableResult func removeCLIProxyAPIConfiguration( - purgeTelemetry: (() -> Bool)? = nil, - clear: () -> Bool = { CLIProxyAPIConnectionSettingsStore.clear() }) async + remove: (() async -> CLIProxyAPIConfigurationRemovalResult)? = nil) async -> CLIProxyAPIConfigurationRemovalResult { let collectorTask = self.stopCLIProxyAPIUsageCollector() await collectorTask?.value - guard clear() else { return .configurationRemovalFailed } - let didPurgeTelemetry: Bool = if let purgeTelemetry { - purgeTelemetry() + let result = if let remove { + await remove() } else { await Task.detached(priority: .utility) { - CostUsageCacheLocations.clearCLIProxyAPIArtifacts() + CLIProxyAPIConnectionSettingsStore.removeAndPurgeTelemetry() }.value } - guard didPurgeTelemetry else { return .telemetryCleanupFailed } - return .removed + if result != .configurationRemovalFailed { + self.invalidateCLIProxyAPICostAttribution() + } + return result } func collectCLIProxyAPIUsageNow( diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 6cda95c444..6054720fd8 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -11,6 +11,12 @@ struct CurrentProviderConfigTokenPublication: Sendable, Equatable { let publicationRevision: UInt64 } +struct TokenRefreshPublicationGuard { + let provider: UsageStore.ProviderPublicationRevision + let tokenSnapshot: UInt64 + let providerConfig: UInt64 +} + struct TokenSnapshotPublication: Sendable, Equatable { let snapshot: CostUsageTokenSnapshot? let publicationRevision: UInt64 @@ -116,6 +122,13 @@ extension UsageStore { self.tokenSnapshotPublicationRevisions[provider] ?? 0 } + func tokenRefreshPublicationGuard(for provider: UsageProvider) -> TokenRefreshPublicationGuard { + TokenRefreshPublicationGuard( + provider: self.providerPublicationRevision(for: provider), + tokenSnapshot: self.tokenSnapshotPublicationRevision(for: provider), + providerConfig: self.settings.providerConfigRevision(for: provider)) + } + func publishTokenSnapshot(_ snapshot: CostUsageTokenSnapshot, for provider: UsageProvider) { self.tokenSnapshots[provider] = snapshot self.publishTokenSnapshotState(snapshot, for: provider) @@ -126,6 +139,17 @@ extension UsageStore { self.publishTokenSnapshotState(nil, for: provider) } + func invalidateCLIProxyAPICostAttribution() { + self.cancelCodexCostCatchUp() + self.cancelSpendDashboardCodexCostCatchUp() + self.publishConfirmedEmptyTokenSnapshot(for: .codex) + self.tokenErrors[.codex] = nil + self.tokenFailureGates[.codex]?.reset() + self.lastTokenFetchAt.removeValue(forKey: .codex) + self.lastTokenFetchScope.removeValue(forKey: .codex) + self.persistWidgetSnapshot(reason: "cliproxyapi-removed") + } + private func publishTokenSnapshotState(_ snapshot: CostUsageTokenSnapshot?, for provider: UsageProvider) { self.tokenSnapshotPublicationRevisions[provider, default: 0] &+= 1 self.tokenSnapshotPublications[provider] = TokenSnapshotPublication( @@ -358,14 +382,14 @@ extension UsageStore { func tokenRefreshPublicationIsCurrent( provider: UsageProvider, - publicationRevision: ProviderPublicationRevision, - providerConfigRevision: UInt64, + publicationGuard: TokenRefreshPublicationGuard, historyDays: Int, costScopeSignature: String, fetchedCredentialScopeFingerprint: String? = nil) -> Bool { - guard self.providerPublicationRevisionIsCurrent(publicationRevision, for: provider), - self.settings.providerConfigRevision(for: provider) == providerConfigRevision, + guard self.providerPublicationRevisionIsCurrent(publicationGuard.provider, for: provider), + self.tokenSnapshotPublicationRevision(for: provider) == publicationGuard.tokenSnapshot, + self.settings.providerConfigRevision(for: provider) == publicationGuard.providerConfig, self.settings.costUsageEnabled, self.isEnabled(provider), self.settings.costUsageHistoryDays == historyDays diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 452b514349..c3a839bdc8 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1450,8 +1450,7 @@ extension UsageStore { } let costScope = self.tokenCostScope(for: provider) let costScopeSignature = self.tokenSnapshotScopeSignature(for: provider) - let publicationRevision = self.providerPublicationRevision(for: provider) - let providerConfigRevision = self.settings.providerConfigRevision(for: provider) + let publicationGuard = self.tokenRefreshPublicationGuard(for: provider) if !force, self.tokenRefreshCanReuseCurrentSnapshot( provider: provider, now: now, @@ -1495,8 +1494,7 @@ extension UsageStore { snapshot: snapshot) guard self.tokenRefreshPublicationIsCurrent( provider: provider, - publicationRevision: publicationRevision, - providerConfigRevision: providerConfigRevision, + publicationGuard: publicationGuard, historyDays: historyDays, costScopeSignature: costScopeSignature, fetchedCredentialScopeFingerprint: snapshot.credentialScopeFingerprint) @@ -1529,8 +1527,7 @@ extension UsageStore { } catch { guard self.tokenRefreshPublicationIsCurrent( provider: provider, - publicationRevision: publicationRevision, - providerConfigRevision: providerConfigRevision, + publicationGuard: publicationGuard, historyDays: historyDays, costScopeSignature: costScopeSignature) else { diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 309d47da99..aab6b43739 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -532,6 +532,12 @@ public struct CLIProxyAPIConnectionSettings: Codable, Equatable, Sendable { } public enum CLIProxyAPIConnectionSettingsStore { + struct SerializedSaveOperations: Sendable { + let store: @Sendable (CLIProxyAPIConnectionSettings) -> Bool + let clearDisconnectedState: @Sendable () -> Bool + let rollback: @Sendable () -> Bool + } + private static let key = KeychainCacheStore.Key( category: "integration", identifier: "cliproxyapi-management") @@ -557,11 +563,36 @@ public enum CLIProxyAPIConnectionSettingsStore { @discardableResult public static func save(_ settings: CLIProxyAPIConnectionSettings) -> Bool { - self.save( + self.saveSerialized( settings, - store: { KeychainCacheStore.storeResult(key: self.key, entry: $0) }, - clearDisconnectedState: { CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected(false) }, - rollback: { KeychainCacheStore.clear(key: self.key) }) + stateRoot: nil, + fileManager: .default, + operations: SerializedSaveOperations( + store: { KeychainCacheStore.storeResult(key: self.key, entry: $0) }, + clearDisconnectedState: { CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected(false) }, + rollback: { KeychainCacheStore.clear(key: self.key) })) + } + + static func saveSerialized( + _ settings: CLIProxyAPIConnectionSettings, + stateRoot: URL?, + fileManager: FileManager, + operations: SerializedSaveOperations) -> Bool + { + do { + return try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( + stateRoot: stateRoot, + fileManager: fileManager) + { + self.save( + settings, + store: operations.store, + clearDisconnectedState: operations.clearDisconnectedState, + rollback: operations.rollback) + } + } catch { + return false + } } static func save( @@ -581,6 +612,51 @@ public enum CLIProxyAPIConnectionSettingsStore { @discardableResult public static func clear() -> Bool { + do { + return try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock(stateRoot: nil) { + self.clearUnserialized() + } + } catch { + return false + } + } + + public static func removeAndPurgeTelemetry() -> CLIProxyAPIConfigurationRemovalResult { + let fileManager = FileManager.default + let directories = CostUsageCacheLocations.directories(fileManager: fileManager) + return self.removeAndPurgeTelemetry( + in: directories, + stateRoot: directories[1].deletingLastPathComponent(), + fileManager: fileManager, + clearConfiguration: { self.clearUnserialized() }) + } + + static func removeAndPurgeTelemetry( + in directories: [URL], + stateRoot: URL?, + fileManager: FileManager, + clearConfiguration: @Sendable () -> Bool) -> CLIProxyAPIConfigurationRemovalResult + { + do { + return try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( + stateRoot: stateRoot, + fileManager: fileManager) + { + guard clearConfiguration() else { return .configurationRemovalFailed } + guard CostUsageCacheLocations.clearCLIProxyAPIArtifactsUnserialized( + in: directories, + fileManager: fileManager) + else { + return .telemetryCleanupFailed + } + return .removed + } + } catch { + return .configurationRemovalFailed + } + } + + private static func clearUnserialized() -> Bool { let wasDisconnected = CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected() guard wasDisconnected || CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected(true) else { return false @@ -595,6 +671,12 @@ public enum CLIProxyAPIConnectionSettingsStore { } } +public enum CLIProxyAPIConfigurationRemovalResult: Equatable, Sendable { + case removed + case configurationRemovalFailed + case telemetryCleanupFailed +} + public enum CLIProxyAPIUsageCollectionResult: Equatable, Sendable { case disabled case notConfigured diff --git a/Sources/CodexBarCore/CostUsageCacheLocations.swift b/Sources/CodexBarCore/CostUsageCacheLocations.swift index 7079362ab2..fbbeaf8287 100644 --- a/Sources/CodexBarCore/CostUsageCacheLocations.swift +++ b/Sources/CodexBarCore/CostUsageCacheLocations.swift @@ -115,7 +115,7 @@ public enum CostUsageCacheLocations { } } - private static func clearCLIProxyAPIArtifactsUnserialized( + static func clearCLIProxyAPIArtifactsUnserialized( in directories: [URL], fileManager: FileManager) -> Bool { diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index 4d1d9abb67..6e7eb2e2fd 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -167,6 +167,58 @@ struct CLIProxyAPIUsageCacheTests { #expect(didRollback) } + @Test + func `configuration removal waits for an in progress save transaction`() async throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-settings-lock-\(UUID().uuidString)", isDirectory: true) + let costUsage = root.appendingPathComponent("cost-usage", isDirectory: true) + try fileManager.createDirectory(at: costUsage, withIntermediateDirectories: true) + let usageFile = costUsage.appendingPathComponent(CostUsageCacheLocations.cliProxyAPIUsageFileName) + try Data("telemetry".utf8).write(to: usageFile) + defer { try? fileManager.removeItem(at: root) } + + let saveEntered = DispatchSemaphore(value: 0) + let releaseSave = DispatchSemaphore(value: 0) + let removalEntered = DispatchSemaphore(value: 0) + let settings = CLIProxyAPIConnectionSettings(managementKey: "test-management-key") + let saveTask = Task.detached { + CLIProxyAPIConnectionSettingsStore.saveSerialized( + settings, + stateRoot: root, + fileManager: .default, + operations: .init( + store: { _ in + saveEntered.signal() + releaseSave.wait() + return true + }, + clearDisconnectedState: { true }, + rollback: { true })) + } + #expect(await Self.waitForSignal(saveEntered, timeout: .now() + 1)) + + let removalTask = Task.detached { + CLIProxyAPIConnectionSettingsStore.removeAndPurgeTelemetry( + in: [costUsage], + stateRoot: root, + fileManager: .default, + clearConfiguration: { + removalEntered.signal() + return true + }) + } + let removalEnteredBeforeSaveFinished = await Self.waitForSignal( + removalEntered, + timeout: .now() + .milliseconds(50)) + #expect(!removalEnteredBeforeSaveFinished) + + releaseSave.signal() + #expect(await saveTask.value) + #expect(await removalTask.value == .removed) + #expect(!FileManager.default.fileExists(atPath: usageFile.path)) + } + @Test func `explicit disconnect prevents collection with persisted settings`() async { let fileManager = FileManager.default diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift index 4674ef024f..e0e47486a9 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift @@ -87,24 +87,21 @@ struct CLIProxyAPIUsageStoreTests { collectorFinished.setValue(true) } store.cliProxyAPIUsageCollectorTask = task - var didClearConfiguration = false var collectorFinishedBeforePurge = false + store.publishTokenSnapshot(Self.tokenSnapshot(), for: .codex) + let publicationRevision = store.tokenSnapshotPublicationRevision(for: .codex) - let removed = await store.removeCLIProxyAPIConfiguration( - purgeTelemetry: { - collectorFinishedBeforePurge = collectorFinished.value - return true - }, - clear: { - didClearConfiguration = true - return true - }) + let removed = await store.removeCLIProxyAPIConfiguration { + collectorFinishedBeforePurge = collectorFinished.value + return .removed + } await task.value #expect(removed == .removed) - #expect(didClearConfiguration) #expect(collectorFinishedBeforePurge) #expect(store.cliProxyAPIUsageCollectorTask == nil) + #expect(store.tokenSnapshot(for: .codex) == nil) + #expect(store.tokenSnapshotPublicationRevision(for: .codex) == publicationRevision + 1) #expect(await recorder.wasCancelled) } @@ -124,17 +121,13 @@ struct CLIProxyAPIUsageStoreTests { settings: settings, startupBehavior: .testing, environmentBase: environment) - var didPurgeTelemetry = false - - let removed = await store.removeCLIProxyAPIConfiguration( - purgeTelemetry: { - didPurgeTelemetry = true - return true - }, - clear: { false }) + store.publishTokenSnapshot(Self.tokenSnapshot(), for: .codex) + let removed = await store.removeCLIProxyAPIConfiguration { + .configurationRemovalFailed + } #expect(removed == .configurationRemovalFailed) - #expect(!didPurgeTelemetry) + #expect(store.tokenSnapshot(for: .codex) != nil) } @Test @@ -153,12 +146,14 @@ struct CLIProxyAPIUsageStoreTests { settings: settings, startupBehavior: .testing, environmentBase: environment) + store.publishTokenSnapshot(Self.tokenSnapshot(), for: .codex) - let removed = await store.removeCLIProxyAPIConfiguration( - purgeTelemetry: { false }, - clear: { true }) + let removed = await store.removeCLIProxyAPIConfiguration { + .telemetryCleanupFailed + } #expect(removed == .telemetryCleanupFailed) + #expect(store.tokenSnapshot(for: .codex) == nil) } @Test @@ -227,6 +222,17 @@ struct CLIProxyAPIUsageStoreTests { #expect(error == nil) #expect(!FileManager.default.fileExists(atPath: cacheDirectory.path)) } + + private static func tokenSnapshot() -> CostUsageTokenSnapshot { + CostUsageTokenSnapshot( + sessionTokens: 10, + sessionCostUSD: 0.01, + last30DaysTokens: 10, + last30DaysCostUSD: 0.01, + currencyCode: "USD", + daily: [], + updatedAt: Date(timeIntervalSince1970: 1_784_203_200)) + } } private final class CLIProxyAPITestFileManager: FileManager { diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index 85af8b03c9..134f874afe 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -163,14 +163,12 @@ struct UsageStoreCoverageTests { historyDays: 30, source: .auto, credentialFingerprint: "unresolved") - let revision = store.providerPublicationRevision(for: .cursor) - let providerConfigRevision = settings.providerConfigRevision(for: .cursor) + let publicationGuard = store.tokenRefreshPublicationGuard(for: .cursor) settings.costUsageHistoryDays = 7 #expect(!store.tokenRefreshPublicationIsCurrent( provider: .cursor, - publicationRevision: revision, - providerConfigRevision: providerConfigRevision, + publicationGuard: publicationGuard, historyDays: 30, costScopeSignature: initialSignature, fetchedCredentialScopeFingerprint: fingerprint)) From ec8b28b2f972064f531e5164cd472667439067d3 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sun, 2 Aug 2026 17:21:07 -0700 Subject: [PATCH 071/183] Invalidate Claude spend on proxy removal --- Sources/CodexBar/UsageStore+TokenCost.swift | 12 +++++++----- .../CLIProxyAPIUsageStoreTests.swift | 15 +++++++++++++-- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 6054720fd8..5873cb8aba 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -142,11 +142,13 @@ extension UsageStore { func invalidateCLIProxyAPICostAttribution() { self.cancelCodexCostCatchUp() self.cancelSpendDashboardCodexCostCatchUp() - self.publishConfirmedEmptyTokenSnapshot(for: .codex) - self.tokenErrors[.codex] = nil - self.tokenFailureGates[.codex]?.reset() - self.lastTokenFetchAt.removeValue(forKey: .codex) - self.lastTokenFetchScope.removeValue(forKey: .codex) + for provider in [UsageProvider.codex, .claude] { + self.publishConfirmedEmptyTokenSnapshot(for: provider) + self.tokenErrors[provider] = nil + self.tokenFailureGates[provider]?.reset() + self.lastTokenFetchAt.removeValue(forKey: provider) + self.lastTokenFetchScope.removeValue(forKey: provider) + } self.persistWidgetSnapshot(reason: "cliproxyapi-removed") } diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift index e0e47486a9..29daccc533 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift @@ -89,7 +89,11 @@ struct CLIProxyAPIUsageStoreTests { store.cliProxyAPIUsageCollectorTask = task var collectorFinishedBeforePurge = false store.publishTokenSnapshot(Self.tokenSnapshot(), for: .codex) - let publicationRevision = store.tokenSnapshotPublicationRevision(for: .codex) + store.publishTokenSnapshot(Self.tokenSnapshot(), for: .claude) + let codexPublicationRevision = store.tokenSnapshotPublicationRevision(for: .codex) + let claudePublicationRevision = store.tokenSnapshotPublicationRevision(for: .claude) + let claudePublicationGuard = store.tokenRefreshPublicationGuard(for: .claude) + let claudeScopeSignature = store.tokenSnapshotScopeSignature(for: .claude) let removed = await store.removeCLIProxyAPIConfiguration { collectorFinishedBeforePurge = collectorFinished.value @@ -101,7 +105,14 @@ struct CLIProxyAPIUsageStoreTests { #expect(collectorFinishedBeforePurge) #expect(store.cliProxyAPIUsageCollectorTask == nil) #expect(store.tokenSnapshot(for: .codex) == nil) - #expect(store.tokenSnapshotPublicationRevision(for: .codex) == publicationRevision + 1) + #expect(store.tokenSnapshotPublicationRevision(for: .codex) == codexPublicationRevision + 1) + #expect(store.tokenSnapshot(for: .claude) == nil) + #expect(store.tokenSnapshotPublicationRevision(for: .claude) == claudePublicationRevision + 1) + #expect(!store.tokenRefreshPublicationIsCurrent( + provider: .claude, + publicationGuard: claudePublicationGuard, + historyDays: settings.costUsageHistoryDays, + costScopeSignature: claudeScopeSignature)) #expect(await recorder.wasCancelled) } From fe3ef911d21ece98eedb17414a37effdcc9347c3 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sun, 2 Aug 2026 17:42:53 -0700 Subject: [PATCH 072/183] Preserve uniquely matched proxy routes --- .../CLIProxyAPIAttributionResolver.swift | 2 +- .../CLIProxyAPIAttributionBatchTests.swift | 41 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift index 658b67f199..c5cc945b2e 100644 --- a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift +++ b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift @@ -238,7 +238,7 @@ struct CLIProxyAPIAttributionResolver: Sendable { routeObserved: routeObservation != nil, configuredCodexModel: configuredCodexModel) : nil - let routeConfirmed = routeObservation != nil || inventoryUpstream != nil + let routeConfirmed = routeObservation != nil || usageRecord != nil || inventoryUpstream != nil var evidence: Set = [.modelProvider] if routeObservation != nil { evidence.insert(.cliProxyRequestLog) diff --git a/Tests/CodexBarTests/CLIProxyAPIAttributionBatchTests.swift b/Tests/CodexBarTests/CLIProxyAPIAttributionBatchTests.swift index b35a1e11f7..112321fbb6 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAttributionBatchTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAttributionBatchTests.swift @@ -114,6 +114,47 @@ struct CLIProxyAPIAttributionBatchTests { #expect(!attributions[1].evidence.contains(.cliProxyRequestLog)) } + @Test + func `uniquely matched telemetry confirms both requests sharing one route observation`() { + let timestamp = Date(timeIntervalSince1970: 1_784_179_200) + let otherTokens = CLIProxyAPIAttributionResolver.TokenSignature( + input: 100, + cacheRead: 300, + cacheCreate: 400, + output: 200) + let resolver = CLIProxyAPIAttributionResolver( + observations: [ + .init(sessionID: "resumed-session", model: "gpt-5.5", timestamp: timestamp), + ], + usageRecords: [ + Self.record(timestamp: timestamp, provider: "codex", authType: "oauth"), + Self.record( + timestamp: timestamp.addingTimeInterval(1), + provider: "openrouter", + authType: "api_key", + tokens: otherTokens), + ]) + let attributions = resolver.attributions(for: [ + .init( + model: "gpt-5.5", + modelProvider: .openAI, + sessionID: "resumed-session", + timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), + tokens: Self.tokens), + .init( + model: "gpt-5.5", + modelProvider: .openAI, + sessionID: "resumed-session", + timestampUnixMs: Int64(timestamp.addingTimeInterval(1).timeIntervalSince1970 * 1000), + tokens: otherTokens), + ]) + + #expect(attributions.map(\.route) == [.cliProxyAPI, .cliProxyAPI]) + #expect(attributions.map(\.upstream?.provider) == ["codex", "openrouter"]) + #expect(attributions.allSatisfy { $0.evidence.contains(.cliProxyUsageTelemetry) }) + #expect(attributions.count(where: { $0.evidence.contains(.cliProxyRequestLog) }) == 1) + } + private static let tokens = CLIProxyAPIAttributionResolver.TokenSignature( input: 10, cacheRead: 30, From dd95cc410fe63107826141f92d247082a1194ef3 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sun, 2 Aug 2026 18:10:29 -0700 Subject: [PATCH 073/183] Tighten proxy evidence and pricing --- .../CLIProxyAPIAttributionResolver.swift | 2 +- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsagePricing.swift | 27 ++++++ .../CostUsage/CostUsageScanner+Claude.swift | 9 ++ .../CLIProxyAPIAttributionResolverTests.swift | 22 +++++ .../CostUsageScannerClaudeFableTests.swift | 87 +++++++++++++++++++ 6 files changed, 147 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift index c5cc945b2e..fb5677caab 100644 --- a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift +++ b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift @@ -279,7 +279,7 @@ struct CLIProxyAPIAttributionResolver: Sendable { } let timestamp = Date(timeIntervalSince1970: Double(timestampUnixMs) / 1000) let candidates = matchingModels.filter { observation in - guard let observationTimestamp = observation.timestamp else { return true } + guard let observationTimestamp = observation.timestamp else { return false } return abs(observationTimestamp.timeIntervalSince(timestamp)) <= Self.maximumRouteMatchDistance } return Self.uniqueClosest( diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index cb0278cacb..c6d54694e4 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 = "df435dc444f3855a" + static let value = "6b577972bae00de3" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index f04a574671..9ea8686373 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -850,3 +850,30 @@ enum CostUsagePricing { cacheRoot: cacheRoot) } } + +extension CostUsagePricing { + static func claudeProxyGoogleCostUSD( + model: String, + inputTokens: Int, + cacheReadInputTokens: Int, + cacheCreationInputTokens: Int, + outputTokens: Int, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil) -> Double? + { + guard let lookup = self.modelsDevLookup( + providerID: "google", + model: model, + catalog: modelsDevCatalog, + cacheRoot: modelsDevCacheRoot) + else { return nil } + return self.claudeCostUSD( + pricing: lookup.pricing, + tokens: ClaudeCostTokens( + input: inputTokens, + cacheRead: cacheReadInputTokens, + cacheCreation: cacheCreationInputTokens, + cacheCreation1h: 0, + output: outputTokens)) + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 68823cf45b..f91c28330a 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -103,6 +103,15 @@ extension CostUsageScanner { pricingDate: context.pricingDate, modelsDevCatalog: context.modelsDevCatalog, modelsDevCacheRoot: context.modelsDevCacheRoot) + } else if pricingProvider == .google { + CostUsagePricing.claudeProxyGoogleCostUSD( + model: pricingModel, + inputTokens: tokens.input, + cacheReadInputTokens: tokens.cacheRead, + cacheCreationInputTokens: tokens.cacheCreate, + outputTokens: tokens.output, + modelsDevCatalog: context.modelsDevCatalog, + modelsDevCacheRoot: context.modelsDevCacheRoot) } else { nil } let normalizedModel = switch modelProvider { case .openAI: diff --git a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift index b68c047a19..3a80a28f2d 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift @@ -882,6 +882,28 @@ struct CLIProxyAPIAttributionResolverTests { } } +struct CLIProxyAPIAttributionTimestampTests { + @Test + func `undated request log does not confirm a dated request`() { + let timestamp = Date(timeIntervalSince1970: 1_784_179_200) + let resolver = CLIProxyAPIAttributionResolver( + observations: [ + .init(sessionID: "session-1", model: "gpt-5.5", timestamp: nil), + ]) + + let attribution = resolver.attribution( + model: "gpt-5.5", + modelProvider: .openAI, + sessionID: "session-1", + timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), + tokens: nil) + + #expect(attribution.route == .unknown) + #expect(attribution.upstream == nil) + #expect(attribution.evidence == [.modelProvider]) + } +} + private actor CLIProxyAPICollectionConcurrencyProbe { private var activeCallCount = 0 private var maximumActiveCount = 0 diff --git a/Tests/CodexBarTests/CostUsageScannerClaudeFableTests.swift b/Tests/CodexBarTests/CostUsageScannerClaudeFableTests.swift index 0d8beb4b75..eeaacd7ca8 100644 --- a/Tests/CodexBarTests/CostUsageScannerClaudeFableTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerClaudeFableTests.swift @@ -43,6 +43,71 @@ struct CostUsageScannerClaudeFableTests { #expect(abs((Double(parsed.rows[0].costNanos) / 1_000_000_000) - expected) < 0.000000001) } + @Test + func `claude proxy google upstream uses models dev pricing`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 9) + let fileURL = try env.writeClaudeProjectFile( + relativePath: "project-a/proxy-google.jsonl", + contents: env.jsonl([ + [ + "message": [ + "model": "proxy-gemini-alias", + "id": "msg_proxy_google", + "type": "message", + "role": "assistant", + "usage": [ + "input_tokens": 100, + "cache_creation_input_tokens": 10, + "cache_read_input_tokens": 20, + "output_tokens": 5, + ], + ], + "requestId": "req_proxy_google", + "type": "assistant", + "timestamp": env.isoString(for: day), + "sessionId": "session_proxy_google", + ], + ])) + let resolver = CLIProxyAPIAttributionResolver( + observations: [ + .init(sessionID: "session_proxy_google", model: "proxy-gemini-alias", timestamp: day), + ], + usageRecords: [ + CLIProxyAPIUsageRecord( + timestamp: day, + provider: "google", + executorType: "GeminiExecutor", + model: "gemini-test-pro", + alias: "proxy-gemini-alias", + endpoint: "POST /v1/messages", + authType: "oauth", + requestID: "req_proxy_google", + tokens: .init( + input: 100, + output: 5, + cacheRead: 20, + cacheCreation: 10, + total: 135)), + ]) + let parsed = try CostUsageScanner.parseClaudeFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + providerFilter: .all, + attributionResolver: resolver, + modelsDevCatalog: Self.googleModelsDevCatalog(model: "gemini-test-pro")) + + let row = try #require(parsed.rows.first) + #expect(row.attribution?.route == .cliProxyAPI) + #expect(row.attribution?.upstream?.provider == "google") + #expect(row.attribution?.upstream?.model == "gemini-test-pro") + let expected = 0.000279 + #expect(abs((Double(row.costNanos) / 1_000_000_000) - expected) < 0.000000001) + #expect(row.costPriced == true) + } + @Test func `claude transcript refusal remains priced without billing provenance`() throws { let env = try CostUsageTestEnvironment() @@ -379,6 +444,28 @@ struct CostUsageScannerClaudeFableTests { return try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8)) } + private static func googleModelsDevCatalog(model: String) throws -> ModelsDevCatalog { + let json = """ + { + "google": { + "id": "google", + "models": { + "\(model)": { + "id": "\(model)", + "cost": { + "input": 2, + "output": 10, + "cache_read": 0.2, + "cache_write": 2.5 + } + } + } + } + } + """ + return try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8)) + } + private static func anthropicThresholdModelsDevCatalog(model: String) throws -> ModelsDevCatalog { let json = """ { From eeec35f928ae02d0bc6dea8a1f4094079d1241eb Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sun, 2 Aug 2026 18:36:11 -0700 Subject: [PATCH 074/183] Tighten proxy maintenance boundaries --- .../PreferencesSpendDashboardPane.swift | 5 +++- Sources/CodexBar/UsageStore+CLIProxyAPI.swift | 8 ++++++ .../CLIProxyAPIAttributionResolver.swift | 10 +++++-- .../CLIProxyAPIUsageTelemetry.swift | 27 +++++++++++++++++++ .../CLIProxyAPIAttributionResolverTests.swift | 22 +++++++++++++++ .../CLIProxyAPIUsageCacheTests.swift | 21 +++++++++++++++ 6 files changed, 90 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index 473f79cc27..78e2bb4d0e 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -481,7 +481,10 @@ struct SpendDashboardPane: View { } let collectorTask = self.store.stopCLIProxyAPIUsageCollector() await collectorTask?.value - guard CLIProxyAPIConnectionSettingsStore.save(configuration) else { + let saved = await Task.detached(priority: .utility) { + CLIProxyAPIConnectionSettingsStore.save(configuration) + }.value + guard saved else { self.store.startCLIProxyAPIUsageCollector() self.cliProxyAPIStatus = "Could not save the management key." return diff --git a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift index cb3084c862..e08c48361b 100644 --- a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift +++ b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift @@ -4,11 +4,19 @@ import Foundation @MainActor extension UsageStore { private static let cliProxyAPIUsageCollectionInterval: Duration = .seconds(30) + private static let cliProxyAPIPendingPruneInterval: Duration = .seconds(24 * 60 * 60) func startCLIProxyAPIUsageCollector() { self.stopCLIProxyAPIUsageCollector() + let pendingPruneInterval = Self.cliProxyAPIPendingPruneInterval self.cliProxyAPIUsageCollectorTask = Task.detached(priority: .utility) { [weak self] in + var nextPendingPruneAt: ContinuousClock.Instant? while !Task.isCancelled { + let now = ContinuousClock.now + if nextPendingPruneAt.map({ now >= $0 }) ?? true { + _ = CLIProxyAPIUsageCollector.prunePendingUsage() + nextPendingPruneAt = now.advanced(by: pendingPruneInterval) + } guard await self?.collectCLIProxyAPIUsageNow() != nil else { return } do { try await Task.sleep(for: Self.cliProxyAPIUsageCollectionInterval) diff --git a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift index fb5677caab..1faab45c54 100644 --- a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift +++ b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift @@ -385,7 +385,7 @@ struct CLIProxyAPIAttributionResolver: Sendable { var recordsByModel: [String: [IndexedUsageRecord]] = [:] for (sourceID, record) in records.enumerated() where !record.failed && record.generate - && record.endpoint.lowercased().contains("/v1/messages") + && self.isClaudeMessagesGenerationEndpoint(record.endpoint) { let models = Set([self.canonicalModel(record.alias), self.canonicalModel(record.model)]) for model in models where !model.isEmpty { @@ -734,7 +734,7 @@ struct CLIProxyAPIAttributionResolver: Sendable { let info = String(text[.. Bool { + let candidate = value.split(whereSeparator: \.isWhitespace).last.map(String.init) ?? value + guard let components = URLComponents(string: candidate) else { return false } + return components.path == "/v1/messages" + } + private static func field(_ name: String, in text: String) -> String? { let prefix = "\(name):" for line in text.split(whereSeparator: \.isNewline) { diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index aab6b43739..0df42e3709 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -719,6 +719,33 @@ public enum CLIProxyAPIUsageCollector { private static let batchSize = 100 private static let collectionGate = CLIProxyAPIUsageCollectionGate() + @discardableResult + public static func prunePendingUsage(now: Date = Date()) -> Bool { + self.prunePendingUsage( + pendingRoot: nil, + stateRoot: nil, + now: now) + } + + @discardableResult + static func prunePendingUsage( + pendingRoot: URL?, + stateRoot: URL?, + now: Date, + fileManager: FileManager = .default) -> Bool + { + do { + return try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( + stateRoot: stateRoot, + fileManager: fileManager) + { + CLIProxyAPIUsagePendingIO.load(pendingRoot: pendingRoot, now: now) != nil + } + } catch { + return false + } + } + public static func collect( cacheRoot: URL? = nil, settings: CLIProxyAPIConnectionSettings? = CLIProxyAPIConnectionSettingsStore.load(), diff --git a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift index 3a80a28f2d..16a8f0a117 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift @@ -904,6 +904,28 @@ struct CLIProxyAPIAttributionTimestampTests { } } +struct CLIProxyAPIAttributionEndpointTests { + @Test(arguments: [ + "/v1/messages", + "/v1/messages?beta=true", + "POST /v1/messages", + "https://localhost:8317/v1/messages?beta=true", + ]) + func `accepts Claude generation endpoint URL variants`(_ endpoint: String) { + #expect(CLIProxyAPIAttributionResolver.isClaudeMessagesGenerationEndpoint(endpoint)) + } + + @Test(arguments: [ + "/v1/messages/count_tokens", + "/v1/messages/batches", + "/v1/messageship", + "POST /v1/messages/count_tokens", + ]) + func `rejects non generation Claude endpoint variants`(_ endpoint: String) { + #expect(!CLIProxyAPIAttributionResolver.isClaudeMessagesGenerationEndpoint(endpoint)) + } +} + private actor CLIProxyAPICollectionConcurrencyProbe { private var activeCallCount = 0 private var maximumActiveCount = 0 diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index 6e7eb2e2fd..452c7ef7c0 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -465,6 +465,27 @@ struct CLIProxyAPIUsageCacheTests { #expect(CLIProxyAPIUsagePendingIO.load(pendingRoot: root, now: now)?.map(\.requestID) == ["retained"]) } + @Test + func `collector maintenance prunes pending journal independently of collection`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-pending-maintenance-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: root) } + let now = try #require(CostUsageDateParser.parse("2026-07-30T12:00:00Z")) + let records = [ + Self.record(id: "expired", timestamp: now.addingTimeInterval(-367 * 24 * 60 * 60)), + Self.record(id: "retained", timestamp: now.addingTimeInterval(-365 * 24 * 60 * 60)), + ] + + #expect(CLIProxyAPIUsagePendingIO.save(records, pendingRoot: root)) + #expect(CLIProxyAPIUsageCollector.prunePendingUsage( + pendingRoot: root, + stateRoot: root, + now: now, + fileManager: fileManager)) + #expect(CLIProxyAPIUsagePendingIO.load(pendingRoot: root, now: now)?.map(\.requestID) == ["retained"]) + } + private static func record(id: String, timestamp: Date) -> CLIProxyAPIUsageRecord { CLIProxyAPIUsageRecord( timestamp: timestamp, From fe868f215451193dcb9548b66e38485317790f6e Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sun, 2 Aug 2026 19:13:47 -0700 Subject: [PATCH 075/183] Harden proxy reconnect and deduplication --- .../CLIProxyAPIUsageTelemetry.swift | 55 ++++++++---- .../CLIProxyAPIUsageCacheTests.swift | 85 +++++++++++++++++++ 2 files changed, 125 insertions(+), 15 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 0df42e3709..e939396f03 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -247,13 +247,10 @@ enum CLIProxyAPIUsageCacheIO { cacheRoot: cacheRoot, legacyCacheRoot: legacyCacheRoot) else { return nil } - var byKey: [String: CLIProxyAPIUsageRecord] = [:] - for record in existingCache.records where record.timestamp >= cutoff { - byKey[self.recordKey(record)] = record - } + var byKey = self.recordsByKey(existingCache.records.filter { $0.timestamp >= cutoff }) let priorCount = byKey.count - for record in records where record.timestamp >= cutoff { - byKey[self.recordKey(record)] = record + for (key, record) in self.recordsByKey(records.filter { $0.timestamp >= cutoff }) { + byKey[key] = record } let cache = Cache(records: byKey.values.sorted { $0.timestamp < $1.timestamp }) if cache == existingCache { @@ -329,6 +326,25 @@ enum CLIProxyAPIUsageCacheIO { ].joined(separator: ":") } + private static func recordsByKey( + _ records: [CLIProxyAPIUsageRecord]) -> [String: CLIProxyAPIUsageRecord] + { + var fallbackOccurrences: [String: Int] = [:] + var recordsByKey: [String: CLIProxyAPIUsageRecord] = [:] + for record in records { + let baseKey = self.recordKey(record) + let requestID = record.requestID.trimmingCharacters(in: .whitespacesAndNewlines) + if !requestID.isEmpty { + recordsByKey[baseKey] = record + continue + } + let occurrence = fallbackOccurrences[baseKey, default: 0] + fallbackOccurrences[baseKey] = occurrence + 1 + recordsByKey["\(baseKey):occurrence:\(occurrence)"] = record + } + return recordsByKey + } + private static func defaultLegacyCacheRoot() -> URL { FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! .appendingPathComponent("CodexBar", isDirectory: true) @@ -388,12 +404,9 @@ enum CLIProxyAPIUsageCacheIO { } private static func mergedCaches(legacy: Cache, durable: Cache?) -> Cache { - var byKey: [String: CLIProxyAPIUsageRecord] = [:] - for record in legacy.records { - byKey[self.recordKey(record)] = record - } - for record in durable?.records ?? [] { - byKey[self.recordKey(record)] = record + var byKey = self.recordsByKey(legacy.records) + for (key, record) in self.recordsByKey(durable?.records ?? []) { + byKey[key] = record } return Cache(records: byKey.values.sorted { $0.timestamp < $1.timestamp }) } @@ -533,6 +546,7 @@ public struct CLIProxyAPIConnectionSettings: Codable, Equatable, Sendable { public enum CLIProxyAPIConnectionSettingsStore { struct SerializedSaveOperations: Sendable { + let prepareForReconnect: @Sendable () -> Bool let store: @Sendable (CLIProxyAPIConnectionSettings) -> Bool let clearDisconnectedState: @Sendable () -> Bool let rollback: @Sendable () -> Bool @@ -563,11 +577,19 @@ public enum CLIProxyAPIConnectionSettingsStore { @discardableResult public static func save(_ settings: CLIProxyAPIConnectionSettings) -> Bool { - self.saveSerialized( + let fileManager = FileManager.default + let directories = CostUsageCacheLocations.directories(fileManager: fileManager) + return self.saveSerialized( settings, - stateRoot: nil, - fileManager: .default, + stateRoot: directories[1].deletingLastPathComponent(), + fileManager: fileManager, operations: SerializedSaveOperations( + prepareForReconnect: { + guard CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected() else { return true } + return CostUsageCacheLocations.clearCLIProxyAPIArtifactsUnserialized( + in: directories, + fileManager: .default) + }, store: { KeychainCacheStore.storeResult(key: self.key, entry: $0) }, clearDisconnectedState: { CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected(false) }, rollback: { KeychainCacheStore.clear(key: self.key) })) @@ -586,6 +608,7 @@ public enum CLIProxyAPIConnectionSettingsStore { { self.save( settings, + prepareForReconnect: operations.prepareForReconnect, store: operations.store, clearDisconnectedState: operations.clearDisconnectedState, rollback: operations.rollback) @@ -597,11 +620,13 @@ public enum CLIProxyAPIConnectionSettingsStore { static func save( _ settings: CLIProxyAPIConnectionSettings, + prepareForReconnect: () -> Bool, store: (CLIProxyAPIConnectionSettings) -> Bool, clearDisconnectedState: () -> Bool, rollback: () -> Bool) -> Bool { guard settings.isConfigured else { return false } + guard prepareForReconnect() else { return false } guard store(settings) else { return false } guard clearDisconnectedState() else { _ = rollback() diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index 452c7ef7c0..15664d9bc5 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -152,6 +152,7 @@ struct CLIProxyAPIUsageCacheTests { let saved = CLIProxyAPIConnectionSettingsStore.save( settings, + prepareForReconnect: { true }, store: { _ in didStore = true return true @@ -167,6 +168,62 @@ struct CLIProxyAPIUsageCacheTests { #expect(didRollback) } + @Test + func `reconnect purges stranded telemetry before storing credentials`() { + let settings = CLIProxyAPIConnectionSettings( + baseURL: "http://127.0.0.1:8317", + managementKey: "test-management-key") + var operations: [String] = [] + + let saved = CLIProxyAPIConnectionSettingsStore.save( + settings, + prepareForReconnect: { + operations.append("purge") + return true + }, + store: { _ in + operations.append("store") + return true + }, + clearDisconnectedState: { + operations.append("clear") + return true + }, + rollback: { + operations.append("rollback") + return true + }) + + #expect(saved) + #expect(operations == ["purge", "store", "clear"]) + } + + @Test + func `reconnect preserves disconnect state when stranded telemetry purge fails`() { + let settings = CLIProxyAPIConnectionSettings( + baseURL: "http://127.0.0.1:8317", + managementKey: "test-management-key") + var didStore = false + var didClearDisconnectedState = false + + let saved = CLIProxyAPIConnectionSettingsStore.save( + settings, + prepareForReconnect: { false }, + store: { _ in + didStore = true + return true + }, + clearDisconnectedState: { + didClearDisconnectedState = true + return true + }, + rollback: { true }) + + #expect(!saved) + #expect(!didStore) + #expect(!didClearDisconnectedState) + } + @Test func `configuration removal waits for an in progress save transaction`() async throws { let fileManager = FileManager.default @@ -188,6 +245,7 @@ struct CLIProxyAPIUsageCacheTests { stateRoot: root, fileManager: .default, operations: .init( + prepareForReconnect: { true }, store: { _ in saveEntered.signal() releaseSave.wait() @@ -404,6 +462,33 @@ struct CLIProxyAPIUsageCacheTests { == records.map { Int64($0.timestamp.timeIntervalSince1970 * 1000) }) } + @Test + func `fallback record identity preserves identical occurrences across cache replay`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-usage-identical-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: root) } + let timestamp = try #require(CostUsageDateParser.parse("2026-07-16T12:00:00.123Z")) + let records = [ + Self.record(id: "", timestamp: timestamp), + Self.record(id: "", timestamp: timestamp), + ] + + #expect(CLIProxyAPIUsageCacheIO.merge( + records, + cacheRoot: root, + now: timestamp) == 2) + #expect(CLIProxyAPIUsageCacheIO.merge( + records, + cacheRoot: root, + now: timestamp) == 0) + + let roundTripped = CLIProxyAPIUsageCacheIO.load( + cacheRoot: root, + now: timestamp) + #expect(roundTripped == records) + } + @Test func `corrupt durable cache is preserved instead of overwritten`() throws { let fileManager = FileManager.default From 041868254d173faddf435dc73966bd1cc537fb60 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sun, 2 Aug 2026 19:38:16 -0700 Subject: [PATCH 076/183] Preserve proxy events and refresh reconnects --- .../PreferencesSpendDashboardPane.swift | 1 + Sources/CodexBar/UsageStore+CLIProxyAPI.swift | 13 +++++++ Sources/CodexBar/UsageStore+TokenCost.swift | 4 +-- .../CLIProxyAPIUsageTelemetry.swift | 35 +++++++++++++++++-- .../CLIProxyAPIUsageCacheTests.swift | 24 +++++++++---- .../CLIProxyAPIUsageCollectorTests.swift | 14 +++++--- .../CLIProxyAPIUsageStoreTests.swift | 34 ++++++++++++++++++ 7 files changed, 108 insertions(+), 17 deletions(-) diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index 78e2bb4d0e..b88d0f1a0c 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -505,6 +505,7 @@ struct SpendDashboardPane: View { case let .failed(message): self.cliProxyAPIStatus = "Saved, but test failed: \(message)" } + await self.store.refreshCLIProxyAPICostAttribution() self.store.startCLIProxyAPIUsageCollector() } diff --git a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift index e08c48361b..39d3a7a594 100644 --- a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift +++ b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift @@ -67,4 +67,17 @@ extension UsageStore { await self?.settings.costUsageEnabled == true }) } + + func refreshCLIProxyAPICostAttribution( + refresh: ((UsageProvider, Bool) async -> Void)? = nil) async + { + self.invalidateCLIProxyAPICostAttribution(widgetReason: "cliproxyapi-reconnected") + for provider in [UsageProvider.claude, .codex] { + if let refresh { + await refresh(provider, true) + } else { + await self.refreshTokenUsageNow(for: provider, force: true) + } + } + } } diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 5873cb8aba..67d9ad6e7a 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -139,7 +139,7 @@ extension UsageStore { self.publishTokenSnapshotState(nil, for: provider) } - func invalidateCLIProxyAPICostAttribution() { + func invalidateCLIProxyAPICostAttribution(widgetReason: String = "cliproxyapi-removed") { self.cancelCodexCostCatchUp() self.cancelSpendDashboardCodexCostCatchUp() for provider in [UsageProvider.codex, .claude] { @@ -149,7 +149,7 @@ extension UsageStore { self.lastTokenFetchAt.removeValue(forKey: provider) self.lastTokenFetchScope.removeValue(forKey: provider) } - self.persistWidgetSnapshot(reason: "cliproxyapi-removed") + self.persistWidgetSnapshot(reason: widgetReason) } private func publishTokenSnapshotState(_ snapshot: CostUsageTokenSnapshot?, for provider: UsageProvider) { diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index e939396f03..d4cf2c4d97 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -61,6 +61,7 @@ struct CLIProxyAPIUsageRecord: Codable, Equatable, Sendable { let endpoint: String let authType: String let requestID: String + let localOccurrenceID: String? let failed: Bool let generate: Bool let tokens: Tokens @@ -74,6 +75,7 @@ struct CLIProxyAPIUsageRecord: Codable, Equatable, Sendable { case endpoint case authType = "auth_type" case requestID = "request_id" + case localOccurrenceID = "codexbar_occurrence_id" case failed case generate case tokens @@ -88,6 +90,7 @@ struct CLIProxyAPIUsageRecord: Codable, Equatable, Sendable { endpoint: String, authType: String, requestID: String, + localOccurrenceID: String? = nil, failed: Bool = false, generate: Bool = true, tokens: Tokens) @@ -100,6 +103,7 @@ struct CLIProxyAPIUsageRecord: Codable, Equatable, Sendable { self.endpoint = endpoint self.authType = authType self.requestID = requestID + self.localOccurrenceID = localOccurrenceID self.failed = failed self.generate = generate self.tokens = tokens @@ -115,11 +119,29 @@ struct CLIProxyAPIUsageRecord: Codable, Equatable, Sendable { self.endpoint = try container.decodeIfPresent(String.self, forKey: .endpoint) ?? "" self.authType = try container.decodeIfPresent(String.self, forKey: .authType) ?? "" self.requestID = try container.decodeIfPresent(String.self, forKey: .requestID) ?? "" + self.localOccurrenceID = try container.decodeIfPresent(String.self, forKey: .localOccurrenceID) self.failed = try container.decodeIfPresent(Bool.self, forKey: .failed) ?? false self.generate = try container.decodeIfPresent(Bool.self, forKey: .generate) ?? true self.tokens = try container.decodeIfPresent(Tokens.self, forKey: .tokens) ?? Tokens(input: 0, output: 0, total: 0) } + + func assigningNewLocalOccurrenceID() -> Self { + guard self.requestID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return self } + return Self( + timestamp: self.timestamp, + provider: self.provider, + executorType: self.executorType, + model: self.model, + alias: self.alias, + endpoint: self.endpoint, + authType: self.authType, + requestID: self.requestID, + localOccurrenceID: UUID().uuidString.lowercased(), + failed: self.failed, + generate: self.generate, + tokens: self.tokens) + } } enum CLIProxyAPIUsageCacheIO { @@ -310,6 +332,10 @@ enum CLIProxyAPIUsageCacheIO { if !requestID.isEmpty { return "request:\(requestID)" } + let localOccurrenceID = record.localOccurrenceID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !localOccurrenceID.isEmpty { + return "occurrence:\(localOccurrenceID)" + } let timestamp = Int64(record.timestamp.timeIntervalSince1970 * 1000) return [ "fallback", @@ -334,7 +360,9 @@ enum CLIProxyAPIUsageCacheIO { for record in records { let baseKey = self.recordKey(record) let requestID = record.requestID.trimmingCharacters(in: .whitespacesAndNewlines) - if !requestID.isEmpty { + let localOccurrenceID = record.localOccurrenceID? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !requestID.isEmpty || !localOccurrenceID.isEmpty { recordsByKey[baseKey] = record continue } @@ -848,16 +876,17 @@ public enum CLIProxyAPIUsageCollector { let poppedBatch = try await Task.detached(priority: .utility) { try await client.pop(count: self.batchSize) }.value + let stagedRecords = poppedBatch.records.map { $0.assigningNewLocalOccurrenceID() } if !poppedBatch.records.isEmpty { guard CLIProxyAPIUsagePendingIO.save( - poppedBatch.records, + stagedRecords, pendingRoot: effectivePendingRoot) else { return .failed("Could not stage CLIProxyAPI usage telemetry.") } } guard let batchAdded = CLIProxyAPIUsageCacheIO.merge( - poppedBatch.records, + stagedRecords, cacheRoot: cacheRoot) else { return .failed("Could not save CLIProxyAPI usage telemetry.") diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index 15664d9bc5..fec0205e57 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -463,30 +463,40 @@ struct CLIProxyAPIUsageCacheTests { } @Test - func `fallback record identity preserves identical occurrences across cache replay`() throws { + func `fallback record identity preserves identical occurrences across batches and replay`() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory .appendingPathComponent("cliproxy-usage-identical-\(UUID().uuidString)", isDirectory: true) defer { try? fileManager.removeItem(at: root) } let timestamp = try #require(CostUsageDateParser.parse("2026-07-16T12:00:00.123Z")) let records = [ - Self.record(id: "", timestamp: timestamp), - Self.record(id: "", timestamp: timestamp), + Self.record(id: "", timestamp: timestamp).assigningNewLocalOccurrenceID(), + Self.record(id: "", timestamp: timestamp).assigningNewLocalOccurrenceID(), ] + #expect(records[0].localOccurrenceID != records[1].localOccurrenceID) #expect(CLIProxyAPIUsageCacheIO.merge( - records, + [records[0]], cacheRoot: root, - now: timestamp) == 2) + now: timestamp) == 1) #expect(CLIProxyAPIUsageCacheIO.merge( - records, + [records[0]], + cacheRoot: root, + now: timestamp) == 0) + #expect(CLIProxyAPIUsageCacheIO.merge( + [records[1]], + cacheRoot: root, + now: timestamp) == 1) + #expect(CLIProxyAPIUsageCacheIO.merge( + [records[1]], cacheRoot: root, now: timestamp) == 0) let roundTripped = CLIProxyAPIUsageCacheIO.load( cacheRoot: root, now: timestamp) - #expect(roundTripped == records) + #expect(roundTripped.count == 2) + #expect(Set(roundTripped.compactMap(\.localOccurrenceID)) == Set(records.compactMap(\.localOccurrenceID))) } @Test diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift index f432fa169b..9e3c026c3c 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift @@ -60,7 +60,7 @@ struct CLIProxyAPIUsageCollectorTests { } @Test - func `persists a popped batch outside a failed cache for the next collection`() async throws { + func `persists an idless popped batch outside a failed cache for the next collection`() async throws { let fileManager = FileManager.default let cacheRoot = fileManager.temporaryDirectory .appendingPathComponent("cliproxy-blocked-\(UUID().uuidString)", isDirectory: false) @@ -79,7 +79,7 @@ struct CLIProxyAPIUsageCollectorTests { alias: "gpt-5.5", endpoint: "POST /v1/messages", authType: "oauth", - requestID: "request-1", + requestID: "", tokens: .init(input: 10, output: 20, total: 30)) let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 @@ -102,12 +102,16 @@ struct CLIProxyAPIUsageCollectorTests { client: client) #expect(result == .failed("Could not save CLIProxyAPI usage telemetry.")) - #expect(CLIProxyAPIUsagePendingIO.load(pendingRoot: pendingRoot)?.map(\.requestID) == ["request-1"]) + let pendingOccurrenceID = try #require( + CLIProxyAPIUsagePendingIO.load(pendingRoot: pendingRoot)?.first?.localOccurrenceID) + #expect(!pendingOccurrenceID.isEmpty) try fileManager.removeItem(at: cacheRoot) let retryClient = CLIProxyAPIUsageQueueClient( settings: .init(managementKey: "management-secret"), dataLoader: { request in - #expect(CLIProxyAPIUsageCacheIO.load(cacheRoot: cacheRoot).map(\.requestID) == ["request-1"]) + #expect(CLIProxyAPIUsageCacheIO.load(cacheRoot: cacheRoot).map(\.localOccurrenceID) == [ + pendingOccurrenceID, + ]) let url = try #require(request.url) let response = try #require(HTTPURLResponse( url: url, @@ -123,7 +127,7 @@ struct CLIProxyAPIUsageCollectorTests { client: retryClient) #expect(retryResult == .collected(1)) - #expect(CLIProxyAPIUsageCacheIO.load(cacheRoot: cacheRoot).map(\.requestID) == ["request-1"]) + #expect(CLIProxyAPIUsageCacheIO.load(cacheRoot: cacheRoot).map(\.localOccurrenceID) == [pendingOccurrenceID]) #expect(!fileManager.fileExists( atPath: CLIProxyAPIUsagePendingIO.pendingFileURL(pendingRoot: pendingRoot).path)) } diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift index 29daccc533..6b78562c1d 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift @@ -167,6 +167,40 @@ struct CLIProxyAPIUsageStoreTests { #expect(store.tokenSnapshot(for: .codex) == nil) } + @Test + func `reconnecting invalidates and force refreshes both proxy affected token snapshots`() async { + let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cliproxy-usage-store-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let environment = [ + "HOME": root.path, + "CODEX_HOME": root.appendingPathComponent(".codex", isDirectory: true).path, + ] + let store = UsageStore( + fetcher: UsageFetcher(environment: environment), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: environment) + store.publishTokenSnapshot(Self.tokenSnapshot(), for: .codex) + store.publishTokenSnapshot(Self.tokenSnapshot(), for: .claude) + let codexPublicationRevision = store.tokenSnapshotPublicationRevision(for: .codex) + let claudePublicationRevision = store.tokenSnapshotPublicationRevision(for: .claude) + var refreshes: [(UsageProvider, Bool)] = [] + + await store.refreshCLIProxyAPICostAttribution { provider, force in + refreshes.append((provider, force)) + } + + #expect(store.tokenSnapshot(for: .codex) == nil) + #expect(store.tokenSnapshotPublicationRevision(for: .codex) == codexPublicationRevision + 1) + #expect(store.tokenSnapshot(for: .claude) == nil) + #expect(store.tokenSnapshotPublicationRevision(for: .claude) == claudePublicationRevision + 1) + #expect(refreshes.map(\.0) == [.claude, .codex]) + #expect(refreshes.map(\.1) == [true, true]) + } + @Test func `clearing cost cache drains the active proxy collector before deletion`() async { let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") From 5b5f6860db9caa26be04c634517c94eb9e729d68 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sun, 2 Aug 2026 19:54:37 -0700 Subject: [PATCH 077/183] Reprice cached Google proxy rows --- .../CostUsage/CostUsageScanner+Claude.swift | 10 +++ .../CostUsageScannerClaudeFableTests.swift | 65 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index f91c28330a..e6e6559341 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -1237,6 +1237,16 @@ extension CostUsageScanner { modelsDevCatalog: modelsDevCatalog, modelsDevCacheRoot: modelsDevCacheRoot) } + if pricingProvider == .google { + return CostUsagePricing.claudeProxyGoogleCostUSD( + model: pricingModel, + inputTokens: row.input, + cacheReadInputTokens: row.cacheRead, + cacheCreationInputTokens: row.cacheCreate, + outputTokens: row.output, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + } guard pricingProvider == .anthropic else { return nil } return CostUsagePricing.claudeCostUSD( model: pricingModel, diff --git a/Tests/CodexBarTests/CostUsageScannerClaudeFableTests.swift b/Tests/CodexBarTests/CostUsageScannerClaudeFableTests.swift index eeaacd7ca8..ddecf2bff6 100644 --- a/Tests/CodexBarTests/CostUsageScannerClaudeFableTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerClaudeFableTests.swift @@ -108,6 +108,71 @@ struct CostUsageScannerClaudeFableTests { #expect(row.costPriced == true) } + @Test + func `claude cached proxy row reprices when reconciliation discovers google upstream`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 9) + let cachedRow = CostUsageScanner.ClaudeUsageRow( + dayKey: "2026-06-09", + model: "proxy-gemini-alias", + sessionId: "session_proxy_google_cached", + messageId: "msg_proxy_google_cached", + requestId: "req_proxy_google_cached", + timestampUnixMs: Int64(day.timeIntervalSince1970 * 1000), + isSidechain: false, + pathRole: .parent, + input: 100, + cacheRead: 20, + cacheCreate: 10, + cacheCreate1h: nil, + output: 5, + costNanos: 1_000_000, + costPriced: true, + attribution: nil) + var cache = CostUsageCache() + cache.files["cached-proxy-google.jsonl"] = CostUsageFileUsage( + mtimeUnixMs: 0, + size: 0, + days: [:], + claudeRows: [cachedRow]) + let resolver = CLIProxyAPIAttributionResolver( + observations: [ + .init(sessionID: "session_proxy_google_cached", model: cachedRow.model, timestamp: day), + ], + usageRecords: [ + CLIProxyAPIUsageRecord( + timestamp: day, + provider: "google", + executorType: "GeminiExecutor", + model: "gemini-test-pro", + alias: cachedRow.model, + endpoint: "POST /v1/messages", + authType: "oauth", + requestID: cachedRow.requestId ?? "", + tokens: .init( + input: cachedRow.input, + output: cachedRow.output, + cacheRead: cachedRow.cacheRead, + cacheCreation: cachedRow.cacheCreate, + total: cachedRow.input + cachedRow.output + cachedRow.cacheRead + cachedRow.cacheCreate)), + ]) + + let report = try CostUsageScanner.buildClaudeReportFromCache( + cache: cache, + range: .init(since: day, until: day), + attributionResolver: resolver, + modelsDevCatalog: Self.googleModelsDevCatalog(model: "gemini-test-pro")) + + let expected = 0.000279 + #expect(abs((report.summary?.totalCostUSD ?? 0) - expected) < 0.000000001) + let breakdown = try #require(report.data.first?.modelBreakdowns?.first) + #expect(breakdown.attribution?.upstream?.provider == "google") + #expect(breakdown.attribution?.upstream?.model == "gemini-test-pro") + #expect(abs((breakdown.costUSD ?? 0) - expected) < 0.000000001) + } + @Test func `claude transcript refusal remains priced without billing provenance`() throws { let env = try CostUsageTestEnvironment() From 3cc6e6e09e02a8f396ba72980d914d5d97c08687 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sun, 2 Aug 2026 20:31:10 -0700 Subject: [PATCH 078/183] Harden proxy route ownership and pricing --- .../CLIProxyAPIAttributionResolver.swift | 18 +++++++++++++++-- Sources/CodexBarCore/CostUsageFetcher.swift | 9 +++++++++ .../CLIProxyAPIAttributionBatchTests.swift | 20 +++++++++++++++++++ ...UsageFetcherUnknownModelPricingTests.swift | 19 ++++++++++++------ 4 files changed, 58 insertions(+), 8 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift index 1faab45c54..f33c4a6ae7 100644 --- a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift +++ b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift @@ -169,9 +169,14 @@ struct CLIProxyAPIAttributionResolver: Sendable { } var routeOwnerByObservation: [ObservationKey: Int] = [:] for (key, candidates) in routeCandidatesByObservation { - if candidates.count == 1, let candidate = candidates.first { + if candidates.count == 1, + let candidate = candidates.first, + prepared[candidate.index].usageRecordMatch != nil + || Self.isCloseRouteMatch(candidate) + { routeOwnerByObservation[key] = candidate.index - } else if let observationTimestamp = candidates.first?.observation.timestamp, + } else if candidates.count > 1, + let observationTimestamp = candidates.first?.observation.timestamp, let candidate = Self.uniqueClosest( candidates, target: observationTimestamp, @@ -212,6 +217,15 @@ struct CLIProxyAPIAttributionResolver: Sendable { } } + private static func isCloseRouteMatch( + _ candidate: (index: Int, request: Request, observation: Observation)) -> Bool + { + guard let requestTimestamp = timestamp(for: candidate.request), + let observationTimestamp = candidate.observation.timestamp + else { return false } + return abs(requestTimestamp.timeIntervalSince(observationTimestamp)) <= Self.maximumTelemetryMatchDistance + } + func hasMatchingObservation(for request: Request) -> Bool { self.matchingObservation( model: request.model, diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 49ab5fb5ee..1bc34dce3a 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -734,6 +734,15 @@ public struct CostUsageFetcher: Sendable { } if attribution?.route == .cliProxyAPI { + let upstreamProvider = attribution?.upstream?.provider + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + switch upstreamProvider { + case "codex", "openai": return ["openai"] + case "claude", "anthropic": return ["anthropic"] + case "aistudio", "gemini", "gemini-interactions", "google", "vertex": return ["google"] + default: break + } return switch attribution?.upstream?.executorType?.lowercased() { case "codexexecutor": ["openai"] case "claudeexecutor": ["anthropic"] diff --git a/Tests/CodexBarTests/CLIProxyAPIAttributionBatchTests.swift b/Tests/CodexBarTests/CLIProxyAPIAttributionBatchTests.swift index 112321fbb6..61bbe9c940 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAttributionBatchTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAttributionBatchTests.swift @@ -114,6 +114,26 @@ struct CLIProxyAPIAttributionBatchTests { #expect(!attributions[1].evidence.contains(.cliProxyRequestLog)) } + @Test + func `orphaned route evidence does not claim a later sole request`() { + let timestamp = Date(timeIntervalSince1970: 1_784_179_200) + let resolver = CLIProxyAPIAttributionResolver( + observations: [ + .init(sessionID: "resumed-session", model: "gpt-5.5", timestamp: timestamp), + ]) + let attribution = resolver.attributions(for: [ + .init( + model: "gpt-5.5", + modelProvider: .openAI, + sessionID: "resumed-session", + timestampUnixMs: Int64(timestamp.addingTimeInterval(30 * 60).timeIntervalSince1970 * 1000), + tokens: Self.tokens), + ])[0] + + #expect(attribution.route == .unknown) + #expect(!attribution.evidence.contains(.cliProxyRequestLog)) + } + @Test func `uniquely matched telemetry confirms both requests sharing one route observation`() { let timestamp = Date(timeIntervalSince1970: 1_784_179_200) diff --git a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift index 64cb9578bc..1b72fc393c 100644 --- a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift @@ -304,11 +304,14 @@ struct CostUsageFetcherUnknownModelPricingTests { } @Test(arguments: [ - ("gpt-new", 0.00028), - ("claude-new", 0.00045), + ("gpt-new", "openrouter", "OpenAICompatExecutor", 0.00028), + ("claude-new", "openrouter", "OpenAICompatExecutor", 0.00045), + ("gemma-new", "gemini", nil, 0.00036), ]) - func `claude fetch resolves OpenAI compatible proxy pricing across model vendors`( + func `claude fetch resolves proxy pricing across upstream providers`( upstreamModel: String, + upstreamProvider: String, + executorType: String?, expectedCost: Double) async throws { let environment = try CostUsageTestEnvironment() @@ -363,8 +366,8 @@ struct CostUsageFetcherUnknownModelPricingTests { [ CLIProxyAPIUsageRecord( timestamp: day, - provider: "openrouter", - executorType: "OpenAICompatExecutor", + provider: upstreamProvider, + executorType: executorType, model: upstreamModel, alias: alias, endpoint: "/v1/messages", @@ -387,6 +390,10 @@ struct CostUsageFetcherUnknownModelPricingTests { "anthropic": { "id": "anthropic", "models": { "claude-new": { "id": "claude-new", "cost": { "input": 3, "output": 15 } } } + }, + "google": { + "id": "google", + "models": { "gemma-new": { "id": "gemma-new", "cost": { "input": 2.5, "output": 11 } } } } } """.utf8) @@ -402,7 +409,7 @@ struct CostUsageFetcherUnknownModelPricingTests { let breakdown = try #require(snapshot.daily.first?.modelBreakdowns?.first) #expect(breakdown.modelName == alias) - #expect(breakdown.attribution?.upstream?.provider == "openrouter") + #expect(breakdown.attribution?.upstream?.provider == upstreamProvider) #expect(breakdown.attribution?.upstream?.model == upstreamModel) #expect(abs((breakdown.costUSD ?? 0) - expectedCost) < 0.0000001) } From 5de7b2ba4745e87f4aac692c7216e0f215ba95fd Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sun, 2 Aug 2026 21:05:20 -0700 Subject: [PATCH 079/183] Prune durable proxy telemetry --- Sources/CodexBar/UsageStore+CLIProxyAPI.swift | 2 +- .../CLIProxyAPIUsageTelemetry.swift | 28 ++++++++++++++++--- .../CLIProxyAPIUsageCacheTests.swift | 12 ++++++-- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift index 39d3a7a594..3207625453 100644 --- a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift +++ b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift @@ -14,7 +14,7 @@ extension UsageStore { while !Task.isCancelled { let now = ContinuousClock.now if nextPendingPruneAt.map({ now >= $0 }) ?? true { - _ = CLIProxyAPIUsageCollector.prunePendingUsage() + _ = CLIProxyAPIUsageCollector.pruneExpiredUsage() nextPendingPruneAt = now.advanced(by: pendingPruneInterval) } guard await self?.collectCLIProxyAPIUsageNow() != nil else { return } diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index d4cf2c4d97..0b9d190b41 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -163,6 +163,22 @@ enum CLIProxyAPIUsageCacheIO { try self.cacheLock.withLock(body) } + static func pruneUnserialized( + cacheRoot: URL?, + now: Date) -> Bool + { + let legacyCacheRoot = cacheRoot == nil ? self.defaultLegacyCacheRoot() : nil + return self.withExclusiveAccess { + guard let currentCache = self.loadCache( + cacheRoot: cacheRoot, + legacyCacheRoot: legacyCacheRoot) + else { return false } + let cutoff = now.addingTimeInterval(-self.maximumRecordAge) + let retainedCache = Cache(records: currentCache.records.filter { $0.timestamp >= cutoff }) + return retainedCache == currentCache || self.save(retainedCache, cacheRoot: cacheRoot) + } + } + static func load( cacheRoot: URL? = nil, now: Date = Date()) -> [CLIProxyAPIUsageRecord] @@ -773,15 +789,17 @@ public enum CLIProxyAPIUsageCollector { private static let collectionGate = CLIProxyAPIUsageCollectionGate() @discardableResult - public static func prunePendingUsage(now: Date = Date()) -> Bool { - self.prunePendingUsage( + public static func pruneExpiredUsage(now: Date = Date()) -> Bool { + self.pruneExpiredUsage( + cacheRoot: nil, pendingRoot: nil, stateRoot: nil, now: now) } @discardableResult - static func prunePendingUsage( + static func pruneExpiredUsage( + cacheRoot: URL?, pendingRoot: URL?, stateRoot: URL?, now: Date, @@ -792,7 +810,9 @@ public enum CLIProxyAPIUsageCollector { stateRoot: stateRoot, fileManager: fileManager) { - CLIProxyAPIUsagePendingIO.load(pendingRoot: pendingRoot, now: now) != nil + let pendingPruned = CLIProxyAPIUsagePendingIO.load(pendingRoot: pendingRoot, now: now) != nil + let durablePruned = CLIProxyAPIUsageCacheIO.pruneUnserialized(cacheRoot: cacheRoot, now: now) + return pendingPruned && durablePruned } } catch { return false diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index fec0205e57..53c05d81b6 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -561,7 +561,7 @@ struct CLIProxyAPIUsageCacheTests { } @Test - func `collector maintenance prunes pending journal independently of collection`() throws { + func `collector maintenance prunes pending and durable usage independently of collection`() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory .appendingPathComponent("cliproxy-pending-maintenance-\(UUID().uuidString)", isDirectory: true) @@ -573,12 +573,20 @@ struct CLIProxyAPIUsageCacheTests { ] #expect(CLIProxyAPIUsagePendingIO.save(records, pendingRoot: root)) - #expect(CLIProxyAPIUsageCollector.prunePendingUsage( + #expect(CLIProxyAPIUsageCacheIO.merge( + records, + cacheRoot: root, + now: records[0].timestamp) == 2) + #expect(CLIProxyAPIUsageCollector.pruneExpiredUsage( + cacheRoot: root, pendingRoot: root, stateRoot: root, now: now, fileManager: fileManager)) #expect(CLIProxyAPIUsagePendingIO.load(pendingRoot: root, now: now)?.map(\.requestID) == ["retained"]) + #expect(CLIProxyAPIUsageCacheIO.load( + cacheRoot: root, + now: now.addingTimeInterval(-365 * 24 * 60 * 60)).map(\.requestID) == ["retained"]) } private static func record(id: String, timestamp: Date) -> CLIProxyAPIUsageRecord { From 029fc9933145fb398453f0f8d7c911ea5b2d5d21 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sun, 2 Aug 2026 21:29:08 -0700 Subject: [PATCH 080/183] Invalidate cross-process proxy disconnects --- Sources/CodexBar/UsageStore+CLIProxyAPI.swift | 24 ++++++++- .../CLIProxyAPIUsageStoreTests.swift | 51 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift index 3207625453..8b8aa42532 100644 --- a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift +++ b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift @@ -11,13 +11,18 @@ extension UsageStore { let pendingPruneInterval = Self.cliProxyAPIPendingPruneInterval self.cliProxyAPIUsageCollectorTask = Task.detached(priority: .utility) { [weak self] in var nextPendingPruneAt: ContinuousClock.Instant? + var handledUnavailableConfiguration = false while !Task.isCancelled { let now = ContinuousClock.now if nextPendingPruneAt.map({ now >= $0 }) ?? true { _ = CLIProxyAPIUsageCollector.pruneExpiredUsage() nextPendingPruneAt = now.advanced(by: pendingPruneInterval) } - guard await self?.collectCLIProxyAPIUsageNow() != nil else { return } + guard let result = await self?.collectCLIProxyAPIUsageNow() else { return } + handledUnavailableConfiguration = await self?.handleCLIProxyAPIUsageCollectionResult( + result, + handledUnavailableConfiguration: handledUnavailableConfiguration) ?? + handledUnavailableConfiguration do { try await Task.sleep(for: Self.cliProxyAPIUsageCollectionInterval) } catch { @@ -68,6 +73,23 @@ extension UsageStore { }) } + func handleCLIProxyAPIUsageCollectionResult( + _ result: CLIProxyAPIUsageCollectionResult, + handledUnavailableConfiguration: Bool) -> Bool + { + switch result { + case .notConfigured: + if !handledUnavailableConfiguration { + self.invalidateCLIProxyAPICostAttribution(widgetReason: "cliproxyapi-disconnected") + } + return true + case .collected, .failed: + return false + case .disabled: + return handledUnavailableConfiguration + } + } + func refreshCLIProxyAPICostAttribution( refresh: ((UsageProvider, Bool) async -> Void)? = nil) async { diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift index 6b78562c1d..2c1e06df96 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift @@ -201,6 +201,57 @@ struct CLIProxyAPIUsageStoreTests { #expect(refreshes.map(\.1) == [true, true]) } + @Test + func `background disconnect invalidates proxy snapshots once per unavailable transition`() { + let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cliproxy-usage-store-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let environment = [ + "HOME": root.path, + "CODEX_HOME": root.appendingPathComponent(".codex", isDirectory: true).path, + ] + let store = UsageStore( + fetcher: UsageFetcher(environment: environment), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: environment) + store.publishTokenSnapshot(Self.tokenSnapshot(), for: .codex) + store.publishTokenSnapshot(Self.tokenSnapshot(), for: .claude) + let codexPublicationRevision = store.tokenSnapshotPublicationRevision(for: .codex) + let claudePublicationRevision = store.tokenSnapshotPublicationRevision(for: .claude) + + var handledUnavailableConfiguration = store.handleCLIProxyAPIUsageCollectionResult( + .notConfigured, + handledUnavailableConfiguration: false) + + #expect(handledUnavailableConfiguration) + #expect(store.tokenSnapshot(for: .codex) == nil) + #expect(store.tokenSnapshot(for: .claude) == nil) + #expect(store.tokenSnapshotPublicationRevision(for: .codex) == codexPublicationRevision + 1) + #expect(store.tokenSnapshotPublicationRevision(for: .claude) == claudePublicationRevision + 1) + + handledUnavailableConfiguration = store.handleCLIProxyAPIUsageCollectionResult( + .notConfigured, + handledUnavailableConfiguration: handledUnavailableConfiguration) + + #expect(store.tokenSnapshotPublicationRevision(for: .codex) == codexPublicationRevision + 1) + #expect(store.tokenSnapshotPublicationRevision(for: .claude) == claudePublicationRevision + 1) + + handledUnavailableConfiguration = store.handleCLIProxyAPIUsageCollectionResult( + .collected(0), + handledUnavailableConfiguration: handledUnavailableConfiguration) + store.publishTokenSnapshot(Self.tokenSnapshot(), for: .codex) + handledUnavailableConfiguration = store.handleCLIProxyAPIUsageCollectionResult( + .notConfigured, + handledUnavailableConfiguration: handledUnavailableConfiguration) + + #expect(handledUnavailableConfiguration) + #expect(store.tokenSnapshot(for: .codex) == nil) + #expect(store.tokenSnapshotPublicationRevision(for: .codex) == codexPublicationRevision + 3) + } + @Test func `clearing cost cache drains the active proxy collector before deletion`() async { let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") From 9b8cd1ea694ff298bcd1f0e1feada8d73bc7c7f6 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sun, 2 Aug 2026 21:50:04 -0700 Subject: [PATCH 081/183] Coordinate proxy cache invalidation --- Sources/CodexBar/UsageStore+TokenCost.swift | 13 ++++ .../UsageStore+TokenRefreshSequence.swift | 17 +++++- Sources/CodexBar/UsageStore.swift | 1 + .../CLIProxyAPIUsageStoreTests.swift | 59 +++++++++++++++++++ 4 files changed, 89 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 67d9ad6e7a..490a18618c 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -142,6 +142,7 @@ extension UsageStore { func invalidateCLIProxyAPICostAttribution(widgetReason: String = "cliproxyapi-removed") { self.cancelCodexCostCatchUp() self.cancelSpendDashboardCodexCostCatchUp() + self.spendDashboardCodexCostCatchUpRevision &+= 1 for provider in [UsageProvider.codex, .claude] { self.publishConfirmedEmptyTokenSnapshot(for: provider) self.tokenErrors[provider] = nil @@ -176,6 +177,9 @@ extension UsageStore { } func clearTokenSnapshots() { + for provider in UsageProvider.allCases { + self.tokenSnapshotPublicationRevisions[provider, default: 0] &+= 1 + } self.tokenSnapshots.removeAll() self.tokenSnapshotPublications.removeAll() } @@ -471,6 +475,10 @@ extension UsageStore { clearDirectories: (@Sendable () async -> String?)? = nil, fileManager: FileManager = .default) async -> String? { + guard !self.costUsageCacheClearInProgress else { return nil } + self.costUsageCacheClearInProgress = true + defer { self.costUsageCacheClearInProgress = false } + let collectorTask = self.stopCLIProxyAPIUsageCollector() await collectorTask?.value defer { @@ -479,6 +487,10 @@ extension UsageStore { } } + await self.drainTokenRefreshesForCostCacheClear() + self.cancelCodexCostCatchUp() + self.cancelSpendDashboardCodexCostCatchUp() + let cacheDirectories = CostUsageCacheLocations.directories(fileManager: fileManager) let cliProxyAPIStateRoot = cacheDirectories[1].deletingLastPathComponent() let errorMessage: String? = if let clearDirectories { @@ -494,6 +506,7 @@ extension UsageStore { guard errorMessage == nil else { return errorMessage } self.clearTokenSnapshots() + self.spendDashboardCodexCostCatchUpRevision &+= 1 self.tokenErrors.removeAll() self.lastTokenFetchAt.removeAll() self.lastTokenFetchScope.removeAll() diff --git a/Sources/CodexBar/UsageStore+TokenRefreshSequence.swift b/Sources/CodexBar/UsageStore+TokenRefreshSequence.swift index ff719fcea5..68715283e1 100644 --- a/Sources/CodexBar/UsageStore+TokenRefreshSequence.swift +++ b/Sources/CodexBar/UsageStore+TokenRefreshSequence.swift @@ -24,7 +24,10 @@ extension UsageStore { } func scheduleTokenRefresh() { - guard self.tokenRefreshSequenceTask == nil, !self.hasForcedRefreshEnrichmentInFlight else { return } + guard !self.costUsageCacheClearInProgress, + self.tokenRefreshSequenceTask == nil, + !self.hasForcedRefreshEnrichmentInFlight + else { return } if self.startPendingTokenRefreshRetryIfPossible() { return } @@ -37,6 +40,7 @@ extension UsageStore { } func refreshTokenUsageNow(for provider: UsageProvider, force: Bool) async { + guard !self.costUsageCacheClearInProgress else { return } if force, self.tokenRefreshSequenceTask != nil, let activeProvider = self.tokenRefreshSequenceProvider, @@ -54,6 +58,17 @@ extension UsageStore { await self.awaitTokenRefreshSequence(task) } + func drainTokenRefreshesForCostCacheClear() async { + self.tokenRefreshRetryProviders.removeAll() + let sequenceTask = self.tokenRefreshSequenceTask + sequenceTask?.cancel() + await sequenceTask?.value + while !self.tokenRefreshInFlight.isEmpty { + await Task.yield() + } + self.tokenRefreshRetryProviders.removeAll() + } + private func serializedTokenRefreshTask( force: Bool, scope: TokenRefreshSequenceScope) async -> Task? diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 2eb5e33e3f..a44b96a7ba 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -184,6 +184,7 @@ final class UsageStore { var tokenSnapshotPublicationRevisions: [UsageProvider: UInt64] = [:] var tokenErrors: [UsageProvider: String] = [:] var tokenRefreshInFlight: Set = [] + @ObservationIgnored var costUsageCacheClearInProgress = false var codexCostCatchUpActivity: CodexCostCatchUpActivity? var spendDashboardCodexCostCatchUpActivity: CodexCostCatchUpActivity? var spendDashboardCodexCostCatchUpRevision: UInt64 = 0 diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift index 2c1e06df96..0b24ebfb0c 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift @@ -204,6 +204,7 @@ struct CLIProxyAPIUsageStoreTests { @Test func `background disconnect invalidates proxy snapshots once per unavailable transition`() { let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") + settings.costUsageEnabled = true let root = FileManager.default.temporaryDirectory .appendingPathComponent("cliproxy-usage-store-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager.default.removeItem(at: root) } @@ -221,6 +222,8 @@ struct CLIProxyAPIUsageStoreTests { store.publishTokenSnapshot(Self.tokenSnapshot(), for: .claude) let codexPublicationRevision = store.tokenSnapshotPublicationRevision(for: .codex) let claudePublicationRevision = store.tokenSnapshotPublicationRevision(for: .claude) + let dashboardRevision = store.spendDashboardCodexCostCatchUpRevision + let dashboardConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) var handledUnavailableConfiguration = store.handleCLIProxyAPIUsageCollectionResult( .notConfigured, @@ -231,6 +234,10 @@ struct CLIProxyAPIUsageStoreTests { #expect(store.tokenSnapshot(for: .claude) == nil) #expect(store.tokenSnapshotPublicationRevision(for: .codex) == codexPublicationRevision + 1) #expect(store.tokenSnapshotPublicationRevision(for: .claude) == claudePublicationRevision + 1) + #expect(store.spendDashboardCodexCostCatchUpRevision == dashboardRevision + 1) + #expect( + SpendDashboardSource.configuration(settings: settings, store: store).sourceRevisions != + dashboardConfiguration.sourceRevisions) handledUnavailableConfiguration = store.handleCLIProxyAPIUsageCollectionResult( .notConfigured, @@ -238,6 +245,7 @@ struct CLIProxyAPIUsageStoreTests { #expect(store.tokenSnapshotPublicationRevision(for: .codex) == codexPublicationRevision + 1) #expect(store.tokenSnapshotPublicationRevision(for: .claude) == claudePublicationRevision + 1) + #expect(store.spendDashboardCodexCostCatchUpRevision == dashboardRevision + 1) handledUnavailableConfiguration = store.handleCLIProxyAPIUsageCollectionResult( .collected(0), @@ -291,6 +299,57 @@ struct CLIProxyAPIUsageStoreTests { #expect(deletionStartedAfterDrain.value) } + @Test + func `clearing cost cache cancels and drains token scans before deletion`() async { + let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") + settings.costUsageEnabled = true + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cliproxy-usage-store-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let environment = [ + "HOME": root.path, + "CODEX_HOME": root.appendingPathComponent(".codex", isDirectory: true).path, + ] + let store = UsageStore( + fetcher: UsageFetcher(environment: environment), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: environment) + let refreshStarted = LockIsolated(false) + let refreshFinished = LockIsolated(false) + let deletionStartedAfterDrain = LockIsolated(false) + store.publishTokenSnapshot(Self.tokenSnapshot(), for: .codex) + let publicationRevision = store.tokenSnapshotPublicationRevision(for: .codex) + store._test_tokenUsageRefreshOverride = { _, _ in + refreshStarted.setValue(true) + while !Task.isCancelled { + await Task.yield() + } + refreshFinished.setValue(true) + } + defer { store._test_tokenUsageRefreshOverride = nil } + let refreshTask = Task { + await store.refreshTokenUsageNow(for: .codex, force: true) + } + while !refreshStarted.value { + await Task.yield() + } + + let error = await store.clearCostUsageCache(clearDirectories: { + deletionStartedAfterDrain.setValue(refreshFinished.value) + return nil + }) + await refreshTask.value + + #expect(error == nil) + #expect(deletionStartedAfterDrain.value) + #expect(store.tokenSnapshot(for: .codex) == nil) + #expect(store.tokenSnapshotPublicationRevision(for: .codex) == publicationRevision + 1) + #expect(store.tokenRefreshInFlight.isEmpty) + #expect(store.tokenRefreshSequenceTask == nil) + } + @Test func `clearing cost cache uses the shared locked deletion path`() async throws { let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") From aa07df2eb257db6ed10e7c63ba0f099fe802762b Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sun, 2 Aug 2026 22:14:02 -0700 Subject: [PATCH 082/183] Require supported proxy route matches --- Sources/CodexBar/UsageStore+CLIProxyAPI.swift | 10 +++++-- .../CLIProxyAPIAttributionResolver.swift | 10 ++++--- .../CLIProxyAPIAttributionBatchTests.swift | 26 +++++++++++++++++++ .../CLIProxyAPIUsageStoreTests.swift | 25 +++++++++++++----- 4 files changed, 58 insertions(+), 13 deletions(-) diff --git a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift index 8b8aa42532..6d73c87c2a 100644 --- a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift +++ b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift @@ -75,7 +75,8 @@ extension UsageStore { func handleCLIProxyAPIUsageCollectionResult( _ result: CLIProxyAPIUsageCollectionResult, - handledUnavailableConfiguration: Bool) -> Bool + handledUnavailableConfiguration: Bool, + refresh: ((UsageProvider, Bool) async -> Void)? = nil) async -> Bool { switch result { case .notConfigured: @@ -83,8 +84,13 @@ extension UsageStore { self.invalidateCLIProxyAPICostAttribution(widgetReason: "cliproxyapi-disconnected") } return true - case .collected, .failed: + case .collected: + if handledUnavailableConfiguration { + await self.refreshCLIProxyAPICostAttribution(refresh: refresh) + } return false + case .failed: + return handledUnavailableConfiguration case .disabled: return handledUnavailableConfiguration } diff --git a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift index f33c4a6ae7..d014563eb4 100644 --- a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift +++ b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift @@ -167,6 +167,9 @@ struct CLIProxyAPIAttributionResolver: Sendable { routeCandidatesByObservation[Self.observationKey(observation), default: []].append( (index: index, request: item.request, observation: observation)) } + let matchCounts = Dictionary( + grouping: prepared.compactMap(\.usageRecordMatch?.key), + by: { $0 }).mapValues(\.count) var routeOwnerByObservation: [ObservationKey: Int] = [:] for (key, candidates) in routeCandidatesByObservation { if candidates.count == 1, @@ -180,14 +183,13 @@ struct CLIProxyAPIAttributionResolver: Sendable { let candidate = Self.uniqueClosest( candidates, target: observationTimestamp, - timestamp: { Self.timestamp(for: $0.request) }) + timestamp: { Self.timestamp(for: $0.request) }), + prepared[candidate.index].usageRecordMatch.map({ matchCounts[$0.key] == 1 }) == true + || Self.isCloseRouteMatch(candidate) { routeOwnerByObservation[key] = candidate.index } } - let matchCounts = Dictionary( - grouping: prepared.compactMap(\.usageRecordMatch?.key), - by: { $0 }).mapValues(\.count) let representedObservations = Set(prepared.compactMap { item -> ObservationKey? in guard item.usageRecordMatch != nil, let observation = item.telemetryObservation diff --git a/Tests/CodexBarTests/CLIProxyAPIAttributionBatchTests.swift b/Tests/CodexBarTests/CLIProxyAPIAttributionBatchTests.swift index 61bbe9c940..2ce3243744 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAttributionBatchTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAttributionBatchTests.swift @@ -134,6 +134,32 @@ struct CLIProxyAPIAttributionBatchTests { #expect(!attribution.evidence.contains(.cliProxyRequestLog)) } + @Test + func `orphaned route evidence does not claim the closest of multiple later requests`() { + let timestamp = Date(timeIntervalSince1970: 1_784_179_200) + let resolver = CLIProxyAPIAttributionResolver( + observations: [ + .init(sessionID: "resumed-session", model: "gpt-5.5", timestamp: timestamp), + ]) + let attributions = resolver.attributions(for: [ + .init( + model: "gpt-5.5", + modelProvider: .openAI, + sessionID: "resumed-session", + timestampUnixMs: Int64(timestamp.addingTimeInterval(30 * 60).timeIntervalSince1970 * 1000), + tokens: Self.tokens), + .init( + model: "gpt-5.5", + modelProvider: .openAI, + sessionID: "resumed-session", + timestampUnixMs: Int64(timestamp.addingTimeInterval(40 * 60).timeIntervalSince1970 * 1000), + tokens: Self.tokens), + ]) + + #expect(attributions.map(\.route) == [.unknown, .unknown]) + #expect(attributions.allSatisfy { !$0.evidence.contains(.cliProxyRequestLog) }) + } + @Test func `uniquely matched telemetry confirms both requests sharing one route observation`() { let timestamp = Date(timeIntervalSince1970: 1_784_179_200) diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift index 0b24ebfb0c..a97a9558bc 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift @@ -202,7 +202,7 @@ struct CLIProxyAPIUsageStoreTests { } @Test - func `background disconnect invalidates proxy snapshots once per unavailable transition`() { + func `background disconnect invalidates once and remote reconnect refreshes snapshots`() async { let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") settings.costUsageEnabled = true let root = FileManager.default.temporaryDirectory @@ -225,7 +225,7 @@ struct CLIProxyAPIUsageStoreTests { let dashboardRevision = store.spendDashboardCodexCostCatchUpRevision let dashboardConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) - var handledUnavailableConfiguration = store.handleCLIProxyAPIUsageCollectionResult( + var handledUnavailableConfiguration = await store.handleCLIProxyAPIUsageCollectionResult( .notConfigured, handledUnavailableConfiguration: false) @@ -239,7 +239,7 @@ struct CLIProxyAPIUsageStoreTests { SpendDashboardSource.configuration(settings: settings, store: store).sourceRevisions != dashboardConfiguration.sourceRevisions) - handledUnavailableConfiguration = store.handleCLIProxyAPIUsageCollectionResult( + handledUnavailableConfiguration = await store.handleCLIProxyAPIUsageCollectionResult( .notConfigured, handledUnavailableConfiguration: handledUnavailableConfiguration) @@ -247,17 +247,28 @@ struct CLIProxyAPIUsageStoreTests { #expect(store.tokenSnapshotPublicationRevision(for: .claude) == claudePublicationRevision + 1) #expect(store.spendDashboardCodexCostCatchUpRevision == dashboardRevision + 1) - handledUnavailableConfiguration = store.handleCLIProxyAPIUsageCollectionResult( - .collected(0), + var refreshes: [(UsageProvider, Bool)] = [] + handledUnavailableConfiguration = await store.handleCLIProxyAPIUsageCollectionResult( + .failed("temporary failure"), handledUnavailableConfiguration: handledUnavailableConfiguration) + #expect(handledUnavailableConfiguration) + handledUnavailableConfiguration = await store.handleCLIProxyAPIUsageCollectionResult( + .collected(0), + handledUnavailableConfiguration: handledUnavailableConfiguration, + refresh: { provider, force in + refreshes.append((provider, force)) + }) + #expect(!handledUnavailableConfiguration) + #expect(refreshes.map(\.0) == [.claude, .codex]) + #expect(refreshes.map(\.1) == [true, true]) store.publishTokenSnapshot(Self.tokenSnapshot(), for: .codex) - handledUnavailableConfiguration = store.handleCLIProxyAPIUsageCollectionResult( + handledUnavailableConfiguration = await store.handleCLIProxyAPIUsageCollectionResult( .notConfigured, handledUnavailableConfiguration: handledUnavailableConfiguration) #expect(handledUnavailableConfiguration) #expect(store.tokenSnapshot(for: .codex) == nil) - #expect(store.tokenSnapshotPublicationRevision(for: .codex) == codexPublicationRevision + 3) + #expect(store.tokenSnapshotPublicationRevision(for: .codex) == codexPublicationRevision + 4) } @Test From 2a76ca31c0d6f56ae78561a8f2d4113e782b4fcc Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sun, 2 Aug 2026 22:48:58 -0700 Subject: [PATCH 083/183] Preserve snapshots without proxy configuration --- Sources/CodexBar/UsageStore+CLIProxyAPI.swift | 42 ++++++++----- .../CostUsageCacheLocations.swift | 2 +- .../CLIProxyAPIUsageStoreTests.swift | 61 ++++++++++++++----- 3 files changed, 76 insertions(+), 29 deletions(-) diff --git a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift index 6d73c87c2a..d5df6809e3 100644 --- a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift +++ b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift @@ -1,6 +1,16 @@ import CodexBarCore import Foundation +struct CLIProxyAPIUsageCollectorState: Equatable { + enum ConfigurationAvailability: Equatable { + case unknown + case available + case unavailable + } + + var configurationAvailability: ConfigurationAvailability = .unknown +} + @MainActor extension UsageStore { private static let cliProxyAPIUsageCollectionInterval: Duration = .seconds(30) @@ -11,7 +21,7 @@ extension UsageStore { let pendingPruneInterval = Self.cliProxyAPIPendingPruneInterval self.cliProxyAPIUsageCollectorTask = Task.detached(priority: .utility) { [weak self] in var nextPendingPruneAt: ContinuousClock.Instant? - var handledUnavailableConfiguration = false + var collectorState = CLIProxyAPIUsageCollectorState() while !Task.isCancelled { let now = ContinuousClock.now if nextPendingPruneAt.map({ now >= $0 }) ?? true { @@ -19,10 +29,9 @@ extension UsageStore { nextPendingPruneAt = now.advanced(by: pendingPruneInterval) } guard let result = await self?.collectCLIProxyAPIUsageNow() else { return } - handledUnavailableConfiguration = await self?.handleCLIProxyAPIUsageCollectionResult( + collectorState = await self?.handleCLIProxyAPIUsageCollectionResult( result, - handledUnavailableConfiguration: handledUnavailableConfiguration) ?? - handledUnavailableConfiguration + collectorState: collectorState) ?? collectorState do { try await Task.sleep(for: Self.cliProxyAPIUsageCollectionInterval) } catch { @@ -75,25 +84,30 @@ extension UsageStore { func handleCLIProxyAPIUsageCollectionResult( _ result: CLIProxyAPIUsageCollectionResult, - handledUnavailableConfiguration: Bool, - refresh: ((UsageProvider, Bool) async -> Void)? = nil) async -> Bool + collectorState: CLIProxyAPIUsageCollectorState, + isExplicitlyDisconnected: () -> Bool = { + CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected() + }, + refresh: ((UsageProvider, Bool) async -> Void)? = nil) async -> CLIProxyAPIUsageCollectorState { + var collectorState = collectorState switch result { case .notConfigured: - if !handledUnavailableConfiguration { + if collectorState.configurationAvailability == .available || + (collectorState.configurationAvailability == .unknown && isExplicitlyDisconnected()) + { self.invalidateCLIProxyAPICostAttribution(widgetReason: "cliproxyapi-disconnected") } - return true + collectorState.configurationAvailability = .unavailable case .collected: - if handledUnavailableConfiguration { + if collectorState.configurationAvailability == .unavailable { await self.refreshCLIProxyAPICostAttribution(refresh: refresh) } - return false - case .failed: - return handledUnavailableConfiguration - case .disabled: - return handledUnavailableConfiguration + collectorState.configurationAvailability = .available + case .failed, .disabled: + break } + return collectorState } func refreshCLIProxyAPICostAttribution( diff --git a/Sources/CodexBarCore/CostUsageCacheLocations.swift b/Sources/CodexBarCore/CostUsageCacheLocations.swift index fbbeaf8287..f9ecab4008 100644 --- a/Sources/CodexBarCore/CostUsageCacheLocations.swift +++ b/Sources/CodexBarCore/CostUsageCacheLocations.swift @@ -140,7 +140,7 @@ public enum CostUsageCacheLocations { return succeeded } - static func isCLIProxyAPIExplicitlyDisconnected( + public static func isCLIProxyAPIExplicitlyDisconnected( stateRoot: URL? = nil, fileManager: FileManager = .default) -> Bool { diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift index a97a9558bc..eaaf218207 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift @@ -201,6 +201,34 @@ struct CLIProxyAPIUsageStoreTests { #expect(refreshes.map(\.1) == [true, true]) } + @Test + func `initial missing configuration preserves hydrated proxy snapshots`() async { + let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") + settings.costUsageEnabled = true + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + let snapshot = Self.tokenSnapshot() + store.publishTokenSnapshot(snapshot, for: .codex) + store.publishTokenSnapshot(snapshot, for: .claude) + let codexPublicationRevision = store.tokenSnapshotPublicationRevision(for: .codex) + let claudePublicationRevision = store.tokenSnapshotPublicationRevision(for: .claude) + + let collectorState = await store.handleCLIProxyAPIUsageCollectionResult( + .notConfigured, + collectorState: CLIProxyAPIUsageCollectorState(), + isExplicitlyDisconnected: { false }) + + #expect(collectorState.configurationAvailability == .unavailable) + #expect(store.tokenSnapshot(for: .codex) == snapshot) + #expect(store.tokenSnapshot(for: .claude) == snapshot) + #expect(store.tokenSnapshotPublicationRevision(for: .codex) == codexPublicationRevision) + #expect(store.tokenSnapshotPublicationRevision(for: .claude) == claudePublicationRevision) + } + @Test func `background disconnect invalidates once and remote reconnect refreshes snapshots`() async { let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") @@ -225,11 +253,12 @@ struct CLIProxyAPIUsageStoreTests { let dashboardRevision = store.spendDashboardCodexCostCatchUpRevision let dashboardConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) - var handledUnavailableConfiguration = await store.handleCLIProxyAPIUsageCollectionResult( + var collectorState = await store.handleCLIProxyAPIUsageCollectionResult( .notConfigured, - handledUnavailableConfiguration: false) + collectorState: CLIProxyAPIUsageCollectorState(configurationAvailability: .available), + isExplicitlyDisconnected: { false }) - #expect(handledUnavailableConfiguration) + #expect(collectorState.configurationAvailability == .unavailable) #expect(store.tokenSnapshot(for: .codex) == nil) #expect(store.tokenSnapshot(for: .claude) == nil) #expect(store.tokenSnapshotPublicationRevision(for: .codex) == codexPublicationRevision + 1) @@ -239,34 +268,38 @@ struct CLIProxyAPIUsageStoreTests { SpendDashboardSource.configuration(settings: settings, store: store).sourceRevisions != dashboardConfiguration.sourceRevisions) - handledUnavailableConfiguration = await store.handleCLIProxyAPIUsageCollectionResult( + collectorState = await store.handleCLIProxyAPIUsageCollectionResult( .notConfigured, - handledUnavailableConfiguration: handledUnavailableConfiguration) + collectorState: collectorState, + isExplicitlyDisconnected: { false }) #expect(store.tokenSnapshotPublicationRevision(for: .codex) == codexPublicationRevision + 1) #expect(store.tokenSnapshotPublicationRevision(for: .claude) == claudePublicationRevision + 1) #expect(store.spendDashboardCodexCostCatchUpRevision == dashboardRevision + 1) var refreshes: [(UsageProvider, Bool)] = [] - handledUnavailableConfiguration = await store.handleCLIProxyAPIUsageCollectionResult( + collectorState = await store.handleCLIProxyAPIUsageCollectionResult( .failed("temporary failure"), - handledUnavailableConfiguration: handledUnavailableConfiguration) - #expect(handledUnavailableConfiguration) - handledUnavailableConfiguration = await store.handleCLIProxyAPIUsageCollectionResult( + collectorState: collectorState, + isExplicitlyDisconnected: { false }) + #expect(collectorState.configurationAvailability == .unavailable) + collectorState = await store.handleCLIProxyAPIUsageCollectionResult( .collected(0), - handledUnavailableConfiguration: handledUnavailableConfiguration, + collectorState: collectorState, + isExplicitlyDisconnected: { false }, refresh: { provider, force in refreshes.append((provider, force)) }) - #expect(!handledUnavailableConfiguration) + #expect(collectorState.configurationAvailability == .available) #expect(refreshes.map(\.0) == [.claude, .codex]) #expect(refreshes.map(\.1) == [true, true]) store.publishTokenSnapshot(Self.tokenSnapshot(), for: .codex) - handledUnavailableConfiguration = await store.handleCLIProxyAPIUsageCollectionResult( + collectorState = await store.handleCLIProxyAPIUsageCollectionResult( .notConfigured, - handledUnavailableConfiguration: handledUnavailableConfiguration) + collectorState: collectorState, + isExplicitlyDisconnected: { false }) - #expect(handledUnavailableConfiguration) + #expect(collectorState.configurationAvailability == .unavailable) #expect(store.tokenSnapshot(for: .codex) == nil) #expect(store.tokenSnapshotPublicationRevision(for: .codex) == codexPublicationRevision + 4) } From 1746aea245774f82d945e86a16e6aadf6e3846f9 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sun, 2 Aug 2026 23:15:42 -0700 Subject: [PATCH 084/183] Preserve temporary proxy credentials --- .../CLIProxyAPIUsageTelemetry.swift | 61 ++++++++++++++++--- .../CLIProxyAPIUsageCollectorTests.swift | 8 +++ 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 0b9d190b41..930826c691 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -601,14 +601,23 @@ public enum CLIProxyAPIConnectionSettingsStore { identifier: "cliproxyapi-management") public static func load() -> CLIProxyAPIConnectionSettings? { - self.load( + guard case let .found(settings) = self.loadResult() else { return nil } + return settings + } + + public static func loadResult() -> KeychainCacheStore.LoadResult { + self.loadResult( isDisconnected: { CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected() }, - loadStored: { - switch KeychainCacheStore.load(key: self.key, as: CLIProxyAPIConnectionSettings.self) { - case let .found(settings): settings - case .missing, .temporarilyUnavailable, .invalid: nil - } - }) + loadStored: { KeychainCacheStore.load(key: self.key, as: CLIProxyAPIConnectionSettings.self) }) + } + + static func loadResult( + isDisconnected: () -> Bool, + loadStored: () -> KeychainCacheStore.LoadResult) + -> KeychainCacheStore.LoadResult + { + guard !isDisconnected() else { return .missing } + return loadStored() } static func load( @@ -821,7 +830,37 @@ public enum CLIProxyAPIUsageCollector { public static func collect( cacheRoot: URL? = nil, - settings: CLIProxyAPIConnectionSettings? = CLIProxyAPIConnectionSettingsStore.load(), + shouldContinue: @escaping @Sendable () async -> Bool = { true }) async + -> CLIProxyAPIUsageCollectionResult + { + await self.collect( + cacheRoot: cacheRoot, + settingsResult: CLIProxyAPIConnectionSettingsStore.loadResult(), + shouldContinue: shouldContinue) + } + + static func collect( + cacheRoot: URL? = nil, + settingsResult: KeychainCacheStore.LoadResult, + shouldContinue: @escaping @Sendable () async -> Bool = { true }) async + -> CLIProxyAPIUsageCollectionResult + { + switch settingsResult { + case let .found(settings): + await self.collect( + cacheRoot: cacheRoot, + settings: settings, + shouldContinue: shouldContinue) + case .temporarilyUnavailable: + .failed("CLIProxyAPI configuration is temporarily unavailable.") + case .missing, .invalid: + .notConfigured + } + } + + public static func collect( + cacheRoot: URL? = nil, + settings: CLIProxyAPIConnectionSettings?, shouldContinue: @escaping @Sendable () async -> Bool = { true }) async -> CLIProxyAPIUsageCollectionResult { @@ -831,7 +870,11 @@ public enum CLIProxyAPIUsageCollector { configurationIsCurrent: { guard !CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected(stateRoot: cacheRoot) else { return false } - return CLIProxyAPIConnectionSettingsStore.load() == settings + return switch CLIProxyAPIConnectionSettingsStore.loadResult() { + case let .found(currentSettings): currentSettings == settings + case .temporarilyUnavailable: true + case .missing, .invalid: false + } }, shouldContinue: shouldContinue, client: CLIProxyAPIUsageQueueClient(settings: settings)) diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift index 9e3c026c3c..f5f921fc93 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift @@ -274,6 +274,14 @@ struct CLIProxyAPIUsageCollectorTests { #expect(await popProbe.popCount == 0) } + @Test + func `temporary credential unavailability remains retryable`() async { + let result = await CLIProxyAPIUsageCollector.collect( + settingsResult: .temporarilyUnavailable) + + #expect(result == .failed("CLIProxyAPI configuration is temporarily unavailable.")) + } + @Test func `collector rechecks configuration before every destructive pop`() async throws { let fileManager = FileManager.default From 7c25cfb195f942126ecd620c902fc13e429cff16 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sun, 2 Aug 2026 23:39:46 -0700 Subject: [PATCH 085/183] Detect missed proxy reconnects --- Sources/CodexBar/UsageStore+CLIProxyAPI.swift | 12 ++++- .../CLIProxyAPIUsageTelemetry.swift | 10 +++- .../CostUsageCacheLocations.swift | 47 +++++++++++++++++++ .../CLIProxyAPIUsageCacheTests.swift | 33 +++++++++++++ .../CLIProxyAPIUsageStoreTests.swift | 34 ++++++++++++++ 5 files changed, 134 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift index d5df6809e3..b7596845cf 100644 --- a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift +++ b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift @@ -9,6 +9,7 @@ struct CLIProxyAPIUsageCollectorState: Equatable { } var configurationAvailability: ConfigurationAvailability = .unknown + var configurationGeneration: String? } @MainActor @@ -88,6 +89,9 @@ extension UsageStore { isExplicitlyDisconnected: () -> Bool = { CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected() }, + configurationGeneration: () -> String? = { + CostUsageCacheLocations.cliProxyAPIConfigurationGeneration() + }, refresh: ((UsageProvider, Bool) async -> Void)? = nil) async -> CLIProxyAPIUsageCollectorState { var collectorState = collectorState @@ -99,11 +103,17 @@ extension UsageStore { self.invalidateCLIProxyAPICostAttribution(widgetReason: "cliproxyapi-disconnected") } collectorState.configurationAvailability = .unavailable + collectorState.configurationGeneration = configurationGeneration() case .collected: - if collectorState.configurationAvailability == .unavailable { + let currentGeneration = configurationGeneration() + if collectorState.configurationAvailability == .unavailable || + (collectorState.configurationAvailability == .available && + collectorState.configurationGeneration != currentGeneration) + { await self.refreshCLIProxyAPICostAttribution(refresh: refresh) } collectorState.configurationAvailability = .available + collectorState.configurationGeneration = currentGeneration case .failed, .disabled: break } diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 930826c691..647e4237fd 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -659,7 +659,11 @@ public enum CLIProxyAPIConnectionSettingsStore { stateRoot: stateRoot, fileManager: fileManager) { - self.save( + guard CostUsageCacheLocations.advanceCLIProxyAPIConfigurationGeneration( + stateRoot: stateRoot, + fileManager: fileManager) + else { return false } + return self.save( settings, prepareForReconnect: operations.prepareForReconnect, store: operations.store, @@ -720,6 +724,10 @@ public enum CLIProxyAPIConnectionSettingsStore { stateRoot: stateRoot, fileManager: fileManager) { + guard CostUsageCacheLocations.advanceCLIProxyAPIConfigurationGeneration( + stateRoot: stateRoot, + fileManager: fileManager) + else { return .configurationRemovalFailed } guard clearConfiguration() else { return .configurationRemovalFailed } guard CostUsageCacheLocations.clearCLIProxyAPIArtifactsUnserialized( in: directories, diff --git a/Sources/CodexBarCore/CostUsageCacheLocations.swift b/Sources/CodexBarCore/CostUsageCacheLocations.swift index f9ecab4008..93fe0a827f 100644 --- a/Sources/CodexBarCore/CostUsageCacheLocations.swift +++ b/Sources/CodexBarCore/CostUsageCacheLocations.swift @@ -16,6 +16,7 @@ public enum CostUsageCacheLocations { static let cliProxyAPIUsageFileName = "cliproxyapi-usage-v1.json" static let cliProxyAPIPendingFileName = "cliproxyapi-pending-v1.json" private static let cliProxyAPIDisconnectedFileName = "cliproxyapi-disconnected-v1" + private static let cliProxyAPIConfigurationGenerationFileName = "cliproxyapi-configuration-generation-v1" public static func directories(fileManager: FileManager = .default) -> [URL] { let cacheRoot = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! @@ -149,6 +150,39 @@ public enum CostUsageCacheLocations { fileManager: fileManager).path) } + public static func cliProxyAPIConfigurationGeneration( + stateRoot: URL? = nil, + fileManager: FileManager = .default) -> String? + { + let url = self.cliProxyAPIConfigurationGenerationURL( + stateRoot: stateRoot, + fileManager: fileManager) + guard let data = try? Data(contentsOf: url), + let generation = String(data: data, encoding: .utf8), + !generation.isEmpty + else { return nil } + return generation + } + + @discardableResult + static func advanceCLIProxyAPIConfigurationGeneration( + stateRoot: URL? = nil, + fileManager: FileManager = .default) -> Bool + { + let url = self.cliProxyAPIConfigurationGenerationURL( + stateRoot: stateRoot, + fileManager: fileManager) + do { + try fileManager.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + try Data(UUID().uuidString.utf8).write(to: url, options: [.atomic]) + return true + } catch { + return false + } + } + @discardableResult static func setCLIProxyAPIExplicitlyDisconnected( _ disconnected: Bool, @@ -191,6 +225,19 @@ public enum CostUsageCacheLocations { return root.appendingPathComponent(self.cliProxyAPIDisconnectedFileName, isDirectory: false) } + private static func cliProxyAPIConfigurationGenerationURL( + stateRoot: URL?, + fileManager: FileManager) -> URL + { + let root = stateRoot ?? fileManager.urls( + for: .applicationSupportDirectory, + in: .userDomainMask).first! + .appendingPathComponent("CodexBar", isDirectory: true) + return root.appendingPathComponent( + self.cliProxyAPIConfigurationGenerationFileName, + isDirectory: false) + } + private static func acquireCLIProxyAPILock( stateRoot: URL?, fileManager: FileManager) throws -> Int32 diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index 53c05d81b6..112ed37c8b 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -224,6 +224,39 @@ struct CLIProxyAPIUsageCacheTests { #expect(!didClearDisconnectedState) } + @Test + func `save and removal advance the durable configuration generation`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-generation-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: root) } + let settings = CLIProxyAPIConnectionSettings(managementKey: "test-management-key") + + #expect(CLIProxyAPIConnectionSettingsStore.saveSerialized( + settings, + stateRoot: root, + fileManager: fileManager, + operations: .init( + prepareForReconnect: { true }, + store: { _ in true }, + clearDisconnectedState: { true }, + rollback: { true }))) + let savedGeneration = try #require(CostUsageCacheLocations.cliProxyAPIConfigurationGeneration( + stateRoot: root, + fileManager: fileManager)) + + #expect(CLIProxyAPIConnectionSettingsStore.removeAndPurgeTelemetry( + in: [], + stateRoot: root, + fileManager: fileManager, + clearConfiguration: { true }) == .removed) + let removedGeneration = try #require(CostUsageCacheLocations.cliProxyAPIConfigurationGeneration( + stateRoot: root, + fileManager: fileManager)) + + #expect(removedGeneration != savedGeneration) + } + @Test func `configuration removal waits for an in progress save transaction`() async throws { let fileManager = FileManager.default diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift index eaaf218207..6a8f622a24 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift @@ -304,6 +304,40 @@ struct CLIProxyAPIUsageStoreTests { #expect(store.tokenSnapshotPublicationRevision(for: .codex) == codexPublicationRevision + 4) } + @Test + func `configuration generation detects a reconnect missed between polls`() async { + let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") + settings.costUsageEnabled = true + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + let snapshot = Self.tokenSnapshot() + store.publishTokenSnapshot(snapshot, for: .codex) + store.publishTokenSnapshot(snapshot, for: .claude) + var refreshes: [(UsageProvider, Bool)] = [] + + let collectorState = await store.handleCLIProxyAPIUsageCollectionResult( + .collected(0), + collectorState: CLIProxyAPIUsageCollectorState( + configurationAvailability: .available, + configurationGeneration: "before-removal"), + isExplicitlyDisconnected: { false }, + configurationGeneration: { "after-reconnect" }, + refresh: { provider, force in + refreshes.append((provider, force)) + }) + + #expect(collectorState.configurationAvailability == .available) + #expect(collectorState.configurationGeneration == "after-reconnect") + #expect(store.tokenSnapshot(for: .codex) == nil) + #expect(store.tokenSnapshot(for: .claude) == nil) + #expect(refreshes.map(\.0) == [.claude, .codex]) + #expect(refreshes.map(\.1) == [true, true]) + } + @Test func `clearing cost cache drains the active proxy collector before deletion`() async { let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") From a5f9d26c5caeb41317dbe0f63b95160d0d4c8ea9 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sun, 2 Aug 2026 23:55:33 -0700 Subject: [PATCH 086/183] Publish proxy generations atomically --- .../CLIProxyAPIUsageTelemetry.swift | 41 +++++++++++++------ .../CostUsageCacheLocations.swift | 40 ++++++++++++++---- .../CLIProxyAPIUsageCacheTests.swift | 21 ++++++++++ 3 files changed, 82 insertions(+), 20 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 647e4237fd..b0218f887c 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -659,16 +659,26 @@ public enum CLIProxyAPIConnectionSettingsStore { stateRoot: stateRoot, fileManager: fileManager) { - guard CostUsageCacheLocations.advanceCLIProxyAPIConfigurationGeneration( - stateRoot: stateRoot, - fileManager: fileManager) + guard let generationUpdate = CostUsageCacheLocations + .prepareCLIProxyAPIConfigurationGenerationUpdate( + stateRoot: stateRoot, + fileManager: fileManager) else { return false } - return self.save( + defer { + CostUsageCacheLocations.discardCLIProxyAPIConfigurationGenerationUpdate( + generationUpdate, + fileManager: fileManager) + } + guard self.save( settings, prepareForReconnect: operations.prepareForReconnect, store: operations.store, clearDisconnectedState: operations.clearDisconnectedState, rollback: operations.rollback) + else { return false } + return CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate( + generationUpdate, + fileManager: fileManager) } } catch { return false @@ -724,18 +734,25 @@ public enum CLIProxyAPIConnectionSettingsStore { stateRoot: stateRoot, fileManager: fileManager) { - guard CostUsageCacheLocations.advanceCLIProxyAPIConfigurationGeneration( - stateRoot: stateRoot, - fileManager: fileManager) + guard let generationUpdate = CostUsageCacheLocations + .prepareCLIProxyAPIConfigurationGenerationUpdate( + stateRoot: stateRoot, + fileManager: fileManager) else { return .configurationRemovalFailed } + defer { + CostUsageCacheLocations.discardCLIProxyAPIConfigurationGenerationUpdate( + generationUpdate, + fileManager: fileManager) + } guard clearConfiguration() else { return .configurationRemovalFailed } - guard CostUsageCacheLocations.clearCLIProxyAPIArtifactsUnserialized( + let artifactsCleared = CostUsageCacheLocations.clearCLIProxyAPIArtifactsUnserialized( in: directories, fileManager: fileManager) - else { - return .telemetryCleanupFailed - } - return .removed + guard CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate( + generationUpdate, + fileManager: fileManager) + else { return .configurationRemovalFailed } + return artifactsCleared ? .removed : .telemetryCleanupFailed } } catch { return .configurationRemovalFailed diff --git a/Sources/CodexBarCore/CostUsageCacheLocations.swift b/Sources/CodexBarCore/CostUsageCacheLocations.swift index 93fe0a827f..7cad955a03 100644 --- a/Sources/CodexBarCore/CostUsageCacheLocations.swift +++ b/Sources/CodexBarCore/CostUsageCacheLocations.swift @@ -13,6 +13,11 @@ public struct CostUsageCacheClearResult: Equatable, Sendable { } public enum CostUsageCacheLocations { + struct CLIProxyAPIConfigurationGenerationUpdate { + let stagedURL: URL + let destinationURL: URL + } + static let cliProxyAPIUsageFileName = "cliproxyapi-usage-v1.json" static let cliProxyAPIPendingFileName = "cliproxyapi-pending-v1.json" private static let cliProxyAPIDisconnectedFileName = "cliproxyapi-disconnected-v1" @@ -164,25 +169,44 @@ public enum CostUsageCacheLocations { return generation } - @discardableResult - static func advanceCLIProxyAPIConfigurationGeneration( + static func prepareCLIProxyAPIConfigurationGenerationUpdate( stateRoot: URL? = nil, - fileManager: FileManager = .default) -> Bool + fileManager: FileManager = .default) -> CLIProxyAPIConfigurationGenerationUpdate? { - let url = self.cliProxyAPIConfigurationGenerationURL( + let destinationURL = self.cliProxyAPIConfigurationGenerationURL( stateRoot: stateRoot, fileManager: fileManager) + let stagedURL = destinationURL + .deletingLastPathComponent() + .appendingPathComponent(".cliproxyapi-generation-\(UUID().uuidString).tmp", isDirectory: false) do { try fileManager.createDirectory( - at: url.deletingLastPathComponent(), + at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true) - try Data(UUID().uuidString.utf8).write(to: url, options: [.atomic]) - return true + try Data(UUID().uuidString.utf8).write(to: stagedURL, options: [.atomic]) + return CLIProxyAPIConfigurationGenerationUpdate( + stagedURL: stagedURL, + destinationURL: destinationURL) } catch { - return false + try? fileManager.removeItem(at: stagedURL) + return nil } } + static func commitCLIProxyAPIConfigurationGenerationUpdate( + _ update: CLIProxyAPIConfigurationGenerationUpdate, + fileManager: FileManager = .default) -> Bool + { + rename(update.stagedURL.path, update.destinationURL.path) == 0 + } + + static func discardCLIProxyAPIConfigurationGenerationUpdate( + _ update: CLIProxyAPIConfigurationGenerationUpdate, + fileManager: FileManager = .default) + { + try? fileManager.removeItem(at: update.stagedURL) + } + @discardableResult static func setCLIProxyAPIExplicitlyDisconnected( _ disconnected: Bool, diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index 112ed37c8b..82c9cf0214 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -257,6 +257,27 @@ struct CLIProxyAPIUsageCacheTests { #expect(removedGeneration != savedGeneration) } + @Test + func `failed save does not publish its staged configuration generation`() { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-generation-failure-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: root) } + + #expect(!CLIProxyAPIConnectionSettingsStore.saveSerialized( + CLIProxyAPIConnectionSettings(managementKey: "test-management-key"), + stateRoot: root, + fileManager: fileManager, + operations: .init( + prepareForReconnect: { false }, + store: { _ in true }, + clearDisconnectedState: { true }, + rollback: { true }))) + #expect(CostUsageCacheLocations.cliProxyAPIConfigurationGeneration( + stateRoot: root, + fileManager: fileManager) == nil) + } + @Test func `configuration removal waits for an in progress save transaction`() async throws { let fileManager = FileManager.default From 0f33a44f21705decc6ea2ea2d3d06a44c85a86b3 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 3 Aug 2026 00:15:25 -0700 Subject: [PATCH 087/183] Purge replaced proxy telemetry --- .../CLIProxyAPIUsageTelemetry.swift | 29 ++++++++++++++--- .../CLIProxyAPIUsageCacheTests.swift | 31 +++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index b0218f887c..81488aa056 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -638,16 +638,37 @@ public enum CLIProxyAPIConnectionSettingsStore { fileManager: fileManager, operations: SerializedSaveOperations( prepareForReconnect: { - guard CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected() else { return true } - return CostUsageCacheLocations.clearCLIProxyAPIArtifactsUnserialized( - in: directories, - fileManager: .default) + self.prepareArtifactsForSave( + settings, + isDisconnected: CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected(), + storedSettings: KeychainCacheStore.load( + key: self.key, + as: CLIProxyAPIConnectionSettings.self), + purge: { + CostUsageCacheLocations.clearCLIProxyAPIArtifactsUnserialized( + in: directories, + fileManager: .default) + }) }, store: { KeychainCacheStore.storeResult(key: self.key, entry: $0) }, clearDisconnectedState: { CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected(false) }, rollback: { KeychainCacheStore.clear(key: self.key) })) } + static func prepareArtifactsForSave( + _ settings: CLIProxyAPIConnectionSettings, + isDisconnected: Bool, + storedSettings: KeychainCacheStore.LoadResult, + purge: () -> Bool) -> Bool + { + if !isDisconnected, case let .found(currentSettings) = storedSettings, + currentSettings == settings + { + return true + } + return purge() + } + static func saveSerialized( _ settings: CLIProxyAPIConnectionSettings, stateRoot: URL?, diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index 82c9cf0214..3e73ed3f6f 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -198,6 +198,37 @@ struct CLIProxyAPIUsageCacheTests { #expect(operations == ["purge", "store", "clear"]) } + @Test + func `active configuration replacement purges prior telemetry`() { + let existing = CLIProxyAPIConnectionSettings( + baseURL: "http://127.0.0.1:8317", + managementKey: "old-management-key") + let replacement = CLIProxyAPIConnectionSettings( + baseURL: "http://127.0.0.1:8318", + managementKey: "new-management-key") + var purgeCount = 0 + + #expect(CLIProxyAPIConnectionSettingsStore.prepareArtifactsForSave( + existing, + isDisconnected: false, + storedSettings: .found(existing), + purge: { + purgeCount += 1 + return true + })) + #expect(purgeCount == 0) + + #expect(CLIProxyAPIConnectionSettingsStore.prepareArtifactsForSave( + replacement, + isDisconnected: false, + storedSettings: .found(existing), + purge: { + purgeCount += 1 + return true + })) + #expect(purgeCount == 1) + } + @Test func `reconnect preserves disconnect state when stranded telemetry purge fails`() { let settings = CLIProxyAPIConnectionSettings( From 8d80053f361a8bff6c0258909d43c9460dabccd6 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 3 Aug 2026 00:49:31 -0700 Subject: [PATCH 088/183] Preserve proxy telemetry on failed saves --- .../CLIProxyAPIUsageTelemetry.swift | 109 +++++++++++----- .../CLIProxyAPIUsageCacheTests.swift | 116 ++++++++++-------- 2 files changed, 140 insertions(+), 85 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 81488aa056..9879f1592b 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -589,11 +589,24 @@ public struct CLIProxyAPIConnectionSettings: Codable, Equatable, Sendable { } public enum CLIProxyAPIConnectionSettingsStore { + enum StoredSettingsSnapshot: Sendable { + case found(CLIProxyAPIConnectionSettings) + case missing + case unavailable + } + + enum ArtifactDisposition: Equatable, Sendable { + case preserve + case purge + } + struct SerializedSaveOperations: Sendable { - let prepareForReconnect: @Sendable () -> Bool + let isDisconnected: @Sendable () -> Bool + let loadStored: @Sendable () -> StoredSettingsSnapshot let store: @Sendable (CLIProxyAPIConnectionSettings) -> Bool + let purgeArtifacts: @Sendable () -> Bool let clearDisconnectedState: @Sendable () -> Bool - let rollback: @Sendable () -> Bool + let restore: @Sendable (StoredSettingsSnapshot) -> Bool } private static let key = KeychainCacheStore.Key( @@ -637,36 +650,57 @@ public enum CLIProxyAPIConnectionSettingsStore { stateRoot: directories[1].deletingLastPathComponent(), fileManager: fileManager, operations: SerializedSaveOperations( - prepareForReconnect: { - self.prepareArtifactsForSave( - settings, - isDisconnected: CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected(), - storedSettings: KeychainCacheStore.load( - key: self.key, - as: CLIProxyAPIConnectionSettings.self), - purge: { - CostUsageCacheLocations.clearCLIProxyAPIArtifactsUnserialized( - in: directories, - fileManager: .default) - }) + isDisconnected: { CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected() }, + loadStored: { + self.storedSettingsSnapshot(from: KeychainCacheStore.load( + key: self.key, + as: CLIProxyAPIConnectionSettings.self)) }, store: { KeychainCacheStore.storeResult(key: self.key, entry: $0) }, + purgeArtifacts: { + CostUsageCacheLocations.clearCLIProxyAPIArtifactsUnserialized( + in: directories, + fileManager: .default) + }, clearDisconnectedState: { CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected(false) }, - rollback: { KeychainCacheStore.clear(key: self.key) })) + restore: { storedSettings in + switch storedSettings { + case let .found(previousSettings): + KeychainCacheStore.storeResult(key: self.key, entry: previousSettings) + case .missing: + KeychainCacheStore.clear(key: self.key) + case .unavailable: + false + } + })) } - static func prepareArtifactsForSave( + static func storedSettingsSnapshot( + from result: KeychainCacheStore.LoadResult) -> StoredSettingsSnapshot + { + switch result { + case let .found(settings): .found(settings) + case .missing: .missing + case .temporarilyUnavailable, .invalid: .unavailable + } + } + + static func artifactDisposition( _ settings: CLIProxyAPIConnectionSettings, isDisconnected: Bool, - storedSettings: KeychainCacheStore.LoadResult, - purge: () -> Bool) -> Bool + storedSettings: StoredSettingsSnapshot) -> ArtifactDisposition? { - if !isDisconnected, case let .found(currentSettings) = storedSettings, - currentSettings == settings - { - return true + switch storedSettings { + case let .found(currentSettings): + if !isDisconnected, currentSettings == settings { + return .preserve + } + return .purge + case .missing: + return .purge + case .unavailable: + return nil } - return purge() } static func saveSerialized( @@ -675,11 +709,18 @@ public enum CLIProxyAPIConnectionSettingsStore { fileManager: FileManager, operations: SerializedSaveOperations) -> Bool { + guard settings.isConfigured else { return false } do { return try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( stateRoot: stateRoot, fileManager: fileManager) { + let storedSettings = operations.loadStored() + guard let artifactDisposition = self.artifactDisposition( + settings, + isDisconnected: operations.isDisconnected(), + storedSettings: storedSettings) + else { return false } guard let generationUpdate = CostUsageCacheLocations .prepareCLIProxyAPIConfigurationGenerationUpdate( stateRoot: stateRoot, @@ -691,11 +732,11 @@ public enum CLIProxyAPIConnectionSettingsStore { fileManager: fileManager) } guard self.save( - settings, - prepareForReconnect: operations.prepareForReconnect, - store: operations.store, + artifactDisposition: artifactDisposition, + store: { operations.store(settings) }, + purgeArtifacts: operations.purgeArtifacts, clearDisconnectedState: operations.clearDisconnectedState, - rollback: operations.rollback) + rollback: { operations.restore(storedSettings) }) else { return false } return CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate( generationUpdate, @@ -707,15 +748,17 @@ public enum CLIProxyAPIConnectionSettingsStore { } static func save( - _ settings: CLIProxyAPIConnectionSettings, - prepareForReconnect: () -> Bool, - store: (CLIProxyAPIConnectionSettings) -> Bool, + artifactDisposition: ArtifactDisposition, + store: () -> Bool, + purgeArtifacts: () -> Bool, clearDisconnectedState: () -> Bool, rollback: () -> Bool) -> Bool { - guard settings.isConfigured else { return false } - guard prepareForReconnect() else { return false } - guard store(settings) else { return false } + guard store() else { return false } + if case .purge = artifactDisposition, !purgeArtifacts() { + _ = rollback() + return false + } guard clearDisconnectedState() else { _ = rollback() return false diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index 3e73ed3f6f..68a5280d9b 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -144,19 +144,16 @@ struct CLIProxyAPIUsageCacheTests { @Test func `reconnect rolls back saved credentials when disconnect state cannot be cleared`() { - let settings = CLIProxyAPIConnectionSettings( - baseURL: "http://127.0.0.1:8317", - managementKey: "test-management-key") var didStore = false var didRollback = false let saved = CLIProxyAPIConnectionSettingsStore.save( - settings, - prepareForReconnect: { true }, - store: { _ in + artifactDisposition: .preserve, + store: { didStore = true return true }, + purgeArtifacts: { true }, clearDisconnectedState: { false }, rollback: { didRollback = true @@ -169,20 +166,17 @@ struct CLIProxyAPIUsageCacheTests { } @Test - func `reconnect purges stranded telemetry before storing credentials`() { - let settings = CLIProxyAPIConnectionSettings( - baseURL: "http://127.0.0.1:8317", - managementKey: "test-management-key") + func `reconnect purges stranded telemetry after storing credentials`() { var operations: [String] = [] let saved = CLIProxyAPIConnectionSettingsStore.save( - settings, - prepareForReconnect: { - operations.append("purge") + artifactDisposition: .purge, + store: { + operations.append("store") return true }, - store: { _ in - operations.append("store") + purgeArtifacts: { + operations.append("purge") return true }, clearDisconnectedState: { @@ -195,64 +189,76 @@ struct CLIProxyAPIUsageCacheTests { }) #expect(saved) - #expect(operations == ["purge", "store", "clear"]) + #expect(operations == ["store", "purge", "clear"]) } @Test - func `active configuration replacement purges prior telemetry`() { + func `active configuration replacement plans a telemetry purge`() { let existing = CLIProxyAPIConnectionSettings( baseURL: "http://127.0.0.1:8317", managementKey: "old-management-key") let replacement = CLIProxyAPIConnectionSettings( baseURL: "http://127.0.0.1:8318", managementKey: "new-management-key") - var purgeCount = 0 - - #expect(CLIProxyAPIConnectionSettingsStore.prepareArtifactsForSave( + #expect(CLIProxyAPIConnectionSettingsStore.artifactDisposition( existing, isDisconnected: false, - storedSettings: .found(existing), - purge: { - purgeCount += 1 - return true - })) - #expect(purgeCount == 0) + storedSettings: .found(existing)) == .preserve) - #expect(CLIProxyAPIConnectionSettingsStore.prepareArtifactsForSave( + #expect(CLIProxyAPIConnectionSettingsStore.artifactDisposition( replacement, isDisconnected: false, - storedSettings: .found(existing), - purge: { - purgeCount += 1 - return true - })) - #expect(purgeCount == 1) + storedSettings: .found(existing)) == .purge) } @Test - func `reconnect preserves disconnect state when stranded telemetry purge fails`() { - let settings = CLIProxyAPIConnectionSettings( - baseURL: "http://127.0.0.1:8317", - managementKey: "test-management-key") + func `replacement keeps prior telemetry when credential storage fails`() { var didStore = false - var didClearDisconnectedState = false + var didPurge = false let saved = CLIProxyAPIConnectionSettingsStore.save( - settings, - prepareForReconnect: { false }, - store: { _ in + artifactDisposition: .purge, + store: { didStore = true + return false + }, + purgeArtifacts: { + didPurge = true return true }, + clearDisconnectedState: { true }, + rollback: { true }) + + #expect(!saved) + #expect(didStore) + #expect(!didPurge) + } + + @Test + func `replacement restores prior credentials when telemetry purge fails`() { + var operations: [String] = [] + + let saved = CLIProxyAPIConnectionSettingsStore.save( + artifactDisposition: .purge, + store: { + operations.append("store") + return true + }, + purgeArtifacts: { + operations.append("purge") + return false + }, clearDisconnectedState: { - didClearDisconnectedState = true + operations.append("clear") return true }, - rollback: { true }) + rollback: { + operations.append("restore") + return true + }) #expect(!saved) - #expect(!didStore) - #expect(!didClearDisconnectedState) + #expect(operations == ["store", "purge", "restore"]) } @Test @@ -268,10 +274,12 @@ struct CLIProxyAPIUsageCacheTests { stateRoot: root, fileManager: fileManager, operations: .init( - prepareForReconnect: { true }, + isDisconnected: { false }, + loadStored: { .missing }, store: { _ in true }, + purgeArtifacts: { true }, clearDisconnectedState: { true }, - rollback: { true }))) + restore: { _ in true }))) let savedGeneration = try #require(CostUsageCacheLocations.cliProxyAPIConfigurationGeneration( stateRoot: root, fileManager: fileManager)) @@ -300,10 +308,12 @@ struct CLIProxyAPIUsageCacheTests { stateRoot: root, fileManager: fileManager, operations: .init( - prepareForReconnect: { false }, - store: { _ in true }, + isDisconnected: { false }, + loadStored: { .missing }, + store: { _ in false }, + purgeArtifacts: { true }, clearDisconnectedState: { true }, - rollback: { true }))) + restore: { _ in true }))) #expect(CostUsageCacheLocations.cliProxyAPIConfigurationGeneration( stateRoot: root, fileManager: fileManager) == nil) @@ -330,14 +340,16 @@ struct CLIProxyAPIUsageCacheTests { stateRoot: root, fileManager: .default, operations: .init( - prepareForReconnect: { true }, + isDisconnected: { false }, + loadStored: { .missing }, store: { _ in saveEntered.signal() releaseSave.wait() return true }, + purgeArtifacts: { true }, clearDisconnectedState: { true }, - rollback: { true })) + restore: { _ in true })) } #expect(await Self.waitForSignal(saveEntered, timeout: .now() + 1)) From 235e5c3af5d4ec97f29e01eec243e37b8b4856f0 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 3 Aug 2026 04:40:37 -0700 Subject: [PATCH 089/183] Close proxy attribution transaction gaps --- Sources/CodexBar/UsageStore+CLIProxyAPI.swift | 10 +- .../CLIProxyAPIAttributionResolver.swift | 85 +++-- .../CLIProxyAPIUsageTelemetry.swift | 192 +++++++---- .../CostUsageCacheLocations.swift | 123 ++++++- .../CLIProxyAPIAttributionResolverTests.swift | 54 ++++ .../CLIProxyAPIUsageCacheTests.swift | 302 ++++++++++++------ .../CLIProxyAPIUsageStoreTests.swift | 35 ++ 7 files changed, 620 insertions(+), 181 deletions(-) diff --git a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift index b7596845cf..c0fd325d0e 100644 --- a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift +++ b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift @@ -114,7 +114,15 @@ extension UsageStore { } collectorState.configurationAvailability = .available collectorState.configurationGeneration = currentGeneration - case .failed, .disabled: + case .failed: + let currentGeneration = configurationGeneration() + if collectorState.configurationAvailability == .available, + collectorState.configurationGeneration != currentGeneration + { + self.invalidateCLIProxyAPICostAttribution(widgetReason: "cliproxyapi-configuration-changed") + collectorState.configurationGeneration = currentGeneration + } + case .disabled: break } return collectorState diff --git a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift index d014563eb4..2261de06cf 100644 --- a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift +++ b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift @@ -2,9 +2,17 @@ import Foundation struct CLIProxyAPIAttributionResolver: Sendable { struct Observation: Sendable, Equatable { + let sourceID: String? let sessionID: String let model: String let timestamp: Date? + + init(sourceID: String? = nil, sessionID: String, model: String, timestamp: Date?) { + self.sourceID = sourceID + self.sessionID = sessionID + self.model = model + self.timestamp = timestamp + } } struct AuthProvider: Sendable, Equatable, Hashable { @@ -28,6 +36,7 @@ struct CLIProxyAPIAttributionResolver: Sendable { } private struct ObservationKey: Hashable { + let sourceID: String? let sessionID: String let canonicalModel: String let timestamp: Date? @@ -139,26 +148,42 @@ struct CLIProxyAPIAttributionResolver: Sendable { } func attributions(for requests: [Request]) -> [CostUsageAttribution] { - let prepared = requests.map { request in - let routeObservation = self.matchingObservation( - model: request.model, - sessionID: request.sessionID, - timestampUnixMs: request.timestampUnixMs) - let telemetryObservation = self.matchingObservation( + var prepared = requests.map { request in + let observationCandidates = self.matchingObservations( model: request.model, sessionID: request.sessionID, timestampUnixMs: request.timestampUnixMs) - let usageRecordMatch = telemetryObservation.flatMap { + let observationMatches = observationCandidates.compactMap { observation in self.closestUsageRecordMatch( - observation: $0, + observation: observation, model: request.model, tokens: request.tokens) } + let matchKeys = Set(observationMatches.map(\.key)) + let usageRecordMatch = observationMatches.count == observationCandidates.count && matchKeys.count == 1 + ? observationMatches.first + : nil + let observation = observationCandidates.count == 1 ? observationCandidates[0] : nil return ( request: request, - routeObservation: routeObservation, - telemetryObservation: telemetryObservation, - usageRecordMatch: usageRecordMatch) + routeObservation: observation, + telemetryObservation: observation, + usageRecordMatch: usageRecordMatch, + observationCandidates: observationCandidates) + } + var claimedObservationKeys = Set(prepared.compactMap(\.routeObservation).map(Self.observationKey)) + for index in prepared.indices where prepared[index].routeObservation == nil + && prepared[index].usageRecordMatch != nil + { + let candidates = prepared[index].observationCandidates.sorted { + ($0.sourceID ?? "") < ($1.sourceID ?? "") + } + guard let observation = candidates.first(where: { + !claimedObservationKeys.contains(Self.observationKey($0)) + }) else { continue } + prepared[index].routeObservation = observation + prepared[index].telemetryObservation = observation + claimedObservationKeys.insert(Self.observationKey(observation)) } var routeCandidatesByObservation: [ObservationKey: [(index: Int, request: Request, observation: Observation)]] = [:] @@ -281,27 +306,38 @@ struct CLIProxyAPIAttributionResolver: Sendable { model: String, sessionID: String?, timestampUnixMs: Int64?) -> Observation? + { + let candidates = self.matchingObservations( + model: model, + sessionID: sessionID, + timestampUnixMs: timestampUnixMs) + return candidates.count == 1 ? candidates[0] : nil + } + + private func matchingObservations( + model: String, + sessionID: String?, + timestampUnixMs: Int64?) -> [Observation] { guard let sessionID = sessionID?.trimmingCharacters(in: .whitespacesAndNewlines), !sessionID.isEmpty, let observations = self.observationsBySessionID[sessionID] - else { return nil } + else { return [] } let canonicalModel = Self.canonicalModel(model) let matchingModels = observations.filter { Self.canonicalModel($0.model) == canonicalModel } - guard !matchingModels.isEmpty else { return nil } + guard !matchingModels.isEmpty else { return [] } guard let timestampUnixMs else { - return matchingModels.count == 1 ? matchingModels[0] : nil + return matchingModels.count == 1 ? matchingModels : [] } let timestamp = Date(timeIntervalSince1970: Double(timestampUnixMs) / 1000) - let candidates = matchingModels.filter { observation in - guard let observationTimestamp = observation.timestamp else { return false } - return abs(observationTimestamp.timeIntervalSince(timestamp)) <= Self.maximumRouteMatchDistance + let ranked = matchingModels.compactMap { observation -> (Observation, TimeInterval)? in + guard let observationTimestamp = observation.timestamp else { return nil } + let distance = abs(observationTimestamp.timeIntervalSince(timestamp)) + return distance <= Self.maximumRouteMatchDistance ? (observation, distance) : nil } - return Self.uniqueClosest( - candidates, - target: timestamp, - timestamp: \.timestamp) + guard let closestDistance = ranked.map(\.1).min() else { return [] } + return ranked.filter { $0.1 == closestDistance }.map(\.0) } private static func timestamp(for request: Request) -> Date? { @@ -390,6 +426,7 @@ struct CLIProxyAPIAttributionResolver: Sendable { private static func observationKey(_ observation: Observation) -> ObservationKey { ObservationKey( + sourceID: observation.sourceID, sessionID: observation.sessionID, canonicalModel: self.canonicalModel(observation.model), timestamp: observation.timestamp) @@ -763,7 +800,11 @@ struct CLIProxyAPIAttributionResolver: Sendable { let requestBody = String(text[bodyStart.. Bool { diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 9879f1592b..8d7f60c28c 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -604,11 +604,23 @@ public enum CLIProxyAPIConnectionSettingsStore { let isDisconnected: @Sendable () -> Bool let loadStored: @Sendable () -> StoredSettingsSnapshot let store: @Sendable (CLIProxyAPIConnectionSettings) -> Bool - let purgeArtifacts: @Sendable () -> Bool - let clearDisconnectedState: @Sendable () -> Bool + let setDisconnectedState: @Sendable (Bool) -> Bool let restore: @Sendable (StoredSettingsSnapshot) -> Bool } + struct SerializedRemovalOperations: Sendable { + let isDisconnected: @Sendable () -> Bool + let loadStored: @Sendable () -> StoredSettingsSnapshot + let clearConfiguration: @Sendable () -> Bool + let setDisconnectedState: @Sendable (Bool) -> Bool + let restore: @Sendable (StoredSettingsSnapshot) -> Bool + } + + struct SerializedRemovalSnapshot: Sendable { + let wasDisconnected: Bool + let storedSettings: StoredSettingsSnapshot + } + private static let key = KeychainCacheStore.Key( category: "integration", identifier: "cliproxyapi-management") @@ -647,6 +659,7 @@ public enum CLIProxyAPIConnectionSettingsStore { let directories = CostUsageCacheLocations.directories(fileManager: fileManager) return self.saveSerialized( settings, + artifactDirectories: directories, stateRoot: directories[1].deletingLastPathComponent(), fileManager: fileManager, operations: SerializedSaveOperations( @@ -657,12 +670,7 @@ public enum CLIProxyAPIConnectionSettingsStore { as: CLIProxyAPIConnectionSettings.self)) }, store: { KeychainCacheStore.storeResult(key: self.key, entry: $0) }, - purgeArtifacts: { - CostUsageCacheLocations.clearCLIProxyAPIArtifactsUnserialized( - in: directories, - fileManager: .default) - }, - clearDisconnectedState: { CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected(false) }, + setDisconnectedState: { CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected($0) }, restore: { storedSettings in switch storedSettings { case let .found(previousSettings): @@ -705,6 +713,7 @@ public enum CLIProxyAPIConnectionSettingsStore { static func saveSerialized( _ settings: CLIProxyAPIConnectionSettings, + artifactDirectories: [URL] = [], stateRoot: URL?, fileManager: FileManager, operations: SerializedSaveOperations) -> Bool @@ -715,10 +724,11 @@ public enum CLIProxyAPIConnectionSettingsStore { stateRoot: stateRoot, fileManager: fileManager) { + let wasDisconnected = operations.isDisconnected() let storedSettings = operations.loadStored() guard let artifactDisposition = self.artifactDisposition( settings, - isDisconnected: operations.isDisconnected(), + isDisconnected: wasDisconnected, storedSettings: storedSettings) else { return false } guard let generationUpdate = CostUsageCacheLocations @@ -731,41 +741,55 @@ public enum CLIProxyAPIConnectionSettingsStore { generationUpdate, fileManager: fileManager) } - guard self.save( - artifactDisposition: artifactDisposition, - store: { operations.store(settings) }, - purgeArtifacts: operations.purgeArtifacts, - clearDisconnectedState: operations.clearDisconnectedState, - rollback: { operations.restore(storedSettings) }) - else { return false } - return CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate( + let artifactsUpdate: CostUsageCacheLocations.CLIProxyAPIArtifactsUpdate? + switch artifactDisposition { + case .preserve: + artifactsUpdate = nil + case .purge: + guard let update = CostUsageCacheLocations.prepareCLIProxyAPIArtifactsUpdate( + in: artifactDirectories, + fileManager: fileManager) + else { return false } + artifactsUpdate = update + } + + func rollback() { + _ = operations.restore(storedSettings) + _ = operations.setDisconnectedState(wasDisconnected) + if let artifactsUpdate { + _ = CostUsageCacheLocations.restoreCLIProxyAPIArtifactsUpdate( + artifactsUpdate, + fileManager: fileManager) + } + } + + guard operations.store(settings) else { + rollback() + return false + } + guard operations.setDisconnectedState(false) else { + rollback() + return false + } + guard CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate( generationUpdate, fileManager: fileManager) + else { + rollback() + return false + } + if let artifactsUpdate { + CostUsageCacheLocations.discardCLIProxyAPIArtifactsUpdate( + artifactsUpdate, + fileManager: fileManager) + } + return true } } catch { return false } } - static func save( - artifactDisposition: ArtifactDisposition, - store: () -> Bool, - purgeArtifacts: () -> Bool, - clearDisconnectedState: () -> Bool, - rollback: () -> Bool) -> Bool - { - guard store() else { return false } - if case .purge = artifactDisposition, !purgeArtifacts() { - _ = rollback() - return false - } - guard clearDisconnectedState() else { - _ = rollback() - return false - } - return true - } - @discardableResult public static func clear() -> Bool { do { @@ -784,45 +808,101 @@ public enum CLIProxyAPIConnectionSettingsStore { in: directories, stateRoot: directories[1].deletingLastPathComponent(), fileManager: fileManager, - clearConfiguration: { self.clearUnserialized() }) + operations: SerializedRemovalOperations( + isDisconnected: { CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected() }, + loadStored: { + self.storedSettingsSnapshot(from: KeychainCacheStore.load( + key: self.key, + as: CLIProxyAPIConnectionSettings.self)) + }, + clearConfiguration: { self.clearUnserialized() }, + setDisconnectedState: { CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected($0) }, + restore: { storedSettings in + switch storedSettings { + case let .found(previousSettings): + KeychainCacheStore.storeResult(key: self.key, entry: previousSettings) + case .missing: + KeychainCacheStore.clear(key: self.key) + case .unavailable: + false + } + })) } static func removeAndPurgeTelemetry( in directories: [URL], stateRoot: URL?, fileManager: FileManager, - clearConfiguration: @Sendable () -> Bool) -> CLIProxyAPIConfigurationRemovalResult + operations: SerializedRemovalOperations) -> CLIProxyAPIConfigurationRemovalResult { do { return try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( stateRoot: stateRoot, fileManager: fileManager) { - guard let generationUpdate = CostUsageCacheLocations - .prepareCLIProxyAPIConfigurationGenerationUpdate( - stateRoot: stateRoot, - fileManager: fileManager) - else { return .configurationRemovalFailed } - defer { - CostUsageCacheLocations.discardCLIProxyAPIConfigurationGenerationUpdate( - generationUpdate, - fileManager: fileManager) - } - guard clearConfiguration() else { return .configurationRemovalFailed } - let artifactsCleared = CostUsageCacheLocations.clearCLIProxyAPIArtifactsUnserialized( + let wasDisconnected = operations.isDisconnected() + let storedSettings = operations.loadStored() + if case .unavailable = storedSettings { return .configurationRemovalFailed } + return self.removeAndPurgeTelemetryUnserialized( in: directories, - fileManager: fileManager) - guard CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate( - generationUpdate, - fileManager: fileManager) - else { return .configurationRemovalFailed } - return artifactsCleared ? .removed : .telemetryCleanupFailed + stateRoot: stateRoot, + fileManager: fileManager, + snapshot: .init( + wasDisconnected: wasDisconnected, + storedSettings: storedSettings), + operations: operations) } } catch { return .configurationRemovalFailed } } + private static func removeAndPurgeTelemetryUnserialized( + in directories: [URL], + stateRoot: URL?, + fileManager: FileManager, + snapshot: SerializedRemovalSnapshot, + operations: SerializedRemovalOperations) -> CLIProxyAPIConfigurationRemovalResult + { + guard let generationUpdate = CostUsageCacheLocations + .prepareCLIProxyAPIConfigurationGenerationUpdate( + stateRoot: stateRoot, + fileManager: fileManager) + else { return .configurationRemovalFailed } + defer { + CostUsageCacheLocations.discardCLIProxyAPIConfigurationGenerationUpdate( + generationUpdate, + fileManager: fileManager) + } + guard let artifactsUpdate = CostUsageCacheLocations.prepareCLIProxyAPIArtifactsUpdate( + in: directories, + fileManager: fileManager) + else { return .configurationRemovalFailed } + + func rollback() { + _ = operations.restore(snapshot.storedSettings) + _ = operations.setDisconnectedState(snapshot.wasDisconnected) + _ = CostUsageCacheLocations.restoreCLIProxyAPIArtifactsUpdate( + artifactsUpdate, + fileManager: fileManager) + } + + guard operations.clearConfiguration() else { + rollback() + return .configurationRemovalFailed + } + guard CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate( + generationUpdate, + fileManager: fileManager) + else { + rollback() + return .configurationRemovalFailed + } + return CostUsageCacheLocations.discardCLIProxyAPIArtifactsUpdate( + artifactsUpdate, + fileManager: fileManager) ? .removed : .telemetryCleanupFailed + } + private static func clearUnserialized() -> Bool { let wasDisconnected = CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected() guard wasDisconnected || CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected(true) else { diff --git a/Sources/CodexBarCore/CostUsageCacheLocations.swift b/Sources/CodexBarCore/CostUsageCacheLocations.swift index 7cad955a03..546a3b6724 100644 --- a/Sources/CodexBarCore/CostUsageCacheLocations.swift +++ b/Sources/CodexBarCore/CostUsageCacheLocations.swift @@ -13,6 +13,15 @@ public struct CostUsageCacheClearResult: Equatable, Sendable { } public enum CostUsageCacheLocations { + struct CLIProxyAPIArtifactsUpdate: Sendable { + struct Move: Sendable { + let originalURL: URL + let stagedURL: URL + } + + let moves: [Move] + } + struct CLIProxyAPIConfigurationGenerationUpdate { let stagedURL: URL let destinationURL: URL @@ -126,24 +135,114 @@ public enum CostUsageCacheLocations { fileManager: FileManager) -> Bool { var succeeded = true - for directory in directories { - let urls = [ + for url in self.cliProxyAPIArtifactURLs(in: directories) { + guard fileManager.fileExists(atPath: url.path) else { continue } + do { + try fileManager.removeItem(at: url) + } catch { + succeeded = false + } + } + return succeeded + } + + static func prepareCLIProxyAPIArtifactsUpdate( + in directories: [URL], + fileManager: FileManager) -> CLIProxyAPIArtifactsUpdate? + { + self.prepareCLIProxyAPIArtifactsUpdate( + in: directories, + fileExists: { fileManager.fileExists(atPath: $0.path) }, + moveItem: { try fileManager.moveItem(at: $0, to: $1) }) + } + + static func prepareCLIProxyAPIArtifactsUpdate( + in directories: [URL], + fileExists: (URL) -> Bool, + moveItem: (URL, URL) throws -> Void) -> CLIProxyAPIArtifactsUpdate? + { + let identifier = UUID().uuidString + var moves: [CLIProxyAPIArtifactsUpdate.Move] = [] + for originalURL in self.cliProxyAPIArtifactURLs(in: directories) where fileExists(originalURL) { + let stagedURL = originalURL + .deletingLastPathComponent() + .appendingPathComponent( + ".\(originalURL.lastPathComponent).\(identifier).replacement-backup", + isDirectory: false) + do { + try moveItem(originalURL, stagedURL) + moves.append(.init(originalURL: originalURL, stagedURL: stagedURL)) + } catch { + _ = self.restoreCLIProxyAPIArtifactsUpdate( + .init(moves: moves), + fileExists: fileExists, + moveItem: moveItem) + return nil + } + } + return CLIProxyAPIArtifactsUpdate(moves: moves) + } + + @discardableResult + static func restoreCLIProxyAPIArtifactsUpdate( + _ update: CLIProxyAPIArtifactsUpdate, + fileManager: FileManager) -> Bool + { + self.restoreCLIProxyAPIArtifactsUpdate( + update, + fileExists: { fileManager.fileExists(atPath: $0.path) }, + moveItem: { try fileManager.moveItem(at: $0, to: $1) }) + } + + @discardableResult + static func restoreCLIProxyAPIArtifactsUpdate( + _ update: CLIProxyAPIArtifactsUpdate, + fileExists: (URL) -> Bool, + moveItem: (URL, URL) throws -> Void) -> Bool + { + var succeeded = true + for move in update.moves.reversed() where fileExists(move.stagedURL) { + guard !fileExists(move.originalURL) else { + succeeded = false + continue + } + do { + try moveItem(move.stagedURL, move.originalURL) + } catch { + succeeded = false + } + } + return succeeded + } + + @discardableResult + static func discardCLIProxyAPIArtifactsUpdate( + _ update: CLIProxyAPIArtifactsUpdate, + fileManager: FileManager) -> Bool + { + var succeeded = true + for move in update.moves { + guard fileManager.fileExists(atPath: move.stagedURL.path) else { continue } + do { + try fileManager.removeItem(at: move.stagedURL) + } catch { + succeeded = false + } + } + return succeeded + } + + private static func cliProxyAPIArtifactURLs(in directories: [URL]) -> [URL] { + var seenPaths: Set = [] + return directories.flatMap { directory in + [ directory.appendingPathComponent(self.cliProxyAPIUsageFileName, isDirectory: false), directory.appendingPathComponent(self.cliProxyAPIPendingFileName, isDirectory: false), CostUsageCacheIO.cacheFileURL( provider: .claude, cacheRoot: directory.deletingLastPathComponent()), ] - for url in urls { - guard fileManager.fileExists(atPath: url.path) else { continue } - do { - try fileManager.removeItem(at: url) - } catch { - succeeded = false - } - } - } - return succeeded + }.filter { seenPaths.insert($0.path).inserted } } public static func isCLIProxyAPIExplicitlyDisconnected( diff --git a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift index 16a8f0a117..a82967a3c7 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift @@ -904,6 +904,60 @@ struct CLIProxyAPIAttributionTimestampTests { } } +struct CLIProxyAPIAttributionEqualTimestampTests { + @Test + func `equal timestamp logs retain distinct observations for token matched telemetry`() { + let timestamp = Date(timeIntervalSince1970: 1_784_179_200) + let firstTokens = CLIProxyAPIAttributionResolver.TokenSignature( + input: 10, + cacheRead: 0, + cacheCreate: 0, + output: 20) + let secondTokens = CLIProxyAPIAttributionResolver.TokenSignature( + input: 30, + cacheRead: 0, + cacheCreate: 0, + output: 40) + let resolver = CLIProxyAPIAttributionResolver( + observations: [ + .init(sourceID: "first.log", sessionID: "session-1", model: "gpt-5.5", timestamp: timestamp), + .init(sourceID: "second.log", sessionID: "session-1", model: "gpt-5.5", timestamp: timestamp), + ], + usageRecords: [ + CLIProxyAPIUsageRecord( + timestamp: timestamp, + provider: "codex", + model: "gpt-5.5", + alias: "gpt-5.5", + endpoint: "POST /v1/messages", + authType: "oauth", + requestID: "request-first", + tokens: .init(input: 10, output: 20, total: 30)), + CLIProxyAPIUsageRecord( + timestamp: timestamp, + provider: "openrouter", + model: "gpt-5.5", + alias: "gpt-5.5", + endpoint: "POST /v1/messages", + authType: "api_key", + requestID: "request-second", + tokens: .init(input: 30, output: 40, total: 70)), + ]) + let attributions = resolver.attributions(for: [firstTokens, secondTokens].map { tokens in + CLIProxyAPIAttributionResolver.Request( + model: "gpt-5.5", + modelProvider: .openAI, + sessionID: "session-1", + timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), + tokens: tokens) + }) + + #expect(attributions.map(\.route) == [.cliProxyAPI, .cliProxyAPI]) + #expect(attributions.map(\.upstream?.provider) == ["codex", "openrouter"]) + #expect(attributions.allSatisfy { $0.evidence.contains(.cliProxyUsageTelemetry) }) + } +} + struct CLIProxyAPIAttributionEndpointTests { @Test(arguments: [ "/v1/messages", diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index 68a5280d9b..1a3c39f00c 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -144,52 +144,63 @@ struct CLIProxyAPIUsageCacheTests { @Test func `reconnect rolls back saved credentials when disconnect state cannot be cleared`() { - var didStore = false - var didRollback = false - - let saved = CLIProxyAPIConnectionSettingsStore.save( - artifactDisposition: .preserve, - store: { - didStore = true - return true - }, - purgeArtifacts: { true }, - clearDisconnectedState: { false }, - rollback: { - didRollback = true - return true - }) + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cliproxy-disconnect-clear-failure-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let didStore = LockIsolated(false) + let didRollback = LockIsolated(false) + let saved = CLIProxyAPIConnectionSettingsStore.saveSerialized( + CLIProxyAPIConnectionSettings(managementKey: "test-management-key"), + stateRoot: root, + fileManager: .default, + operations: .init( + isDisconnected: { true }, + loadStored: { .missing }, + store: { _ in + didStore.setValue(true) + return true + }, + setDisconnectedState: { disconnected in disconnected }, + restore: { _ in + didRollback.setValue(true) + return true + })) #expect(!saved) - #expect(didStore) - #expect(didRollback) + #expect(didStore.value) + #expect(didRollback.value) } @Test - func `reconnect purges stranded telemetry after storing credentials`() { - var operations: [String] = [] - - let saved = CLIProxyAPIConnectionSettingsStore.save( - artifactDisposition: .purge, - store: { - operations.append("store") - return true - }, - purgeArtifacts: { - operations.append("purge") - return true - }, - clearDisconnectedState: { - operations.append("clear") - return true - }, - rollback: { - operations.append("rollback") - return true - }) + func `reconnect stages stranded telemetry before storing credentials`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-reconnect-stage-\(UUID().uuidString)", isDirectory: true) + let costUsage = root.appendingPathComponent("cost-usage", isDirectory: true) + let usageFile = costUsage.appendingPathComponent(CostUsageCacheLocations.cliProxyAPIUsageFileName) + try fileManager.createDirectory(at: costUsage, withIntermediateDirectories: true) + try Data("telemetry".utf8).write(to: usageFile) + defer { try? fileManager.removeItem(at: root) } + + let artifactWasStagedAtStore = LockIsolated(false) + let saved = CLIProxyAPIConnectionSettingsStore.saveSerialized( + CLIProxyAPIConnectionSettings(managementKey: "test-management-key"), + artifactDirectories: [costUsage], + stateRoot: root, + fileManager: fileManager, + operations: .init( + isDisconnected: { true }, + loadStored: { .missing }, + store: { _ in + artifactWasStagedAtStore.setValue(!FileManager.default.fileExists(atPath: usageFile.path)) + return true + }, + setDisconnectedState: { _ in true }, + restore: { _ in true })) #expect(saved) - #expect(operations == ["store", "purge", "clear"]) + #expect(artifactWasStagedAtStore.value) + #expect(!fileManager.fileExists(atPath: usageFile.path)) } @Test @@ -212,53 +223,62 @@ struct CLIProxyAPIUsageCacheTests { } @Test - func `replacement keeps prior telemetry when credential storage fails`() { - var didStore = false - var didPurge = false - - let saved = CLIProxyAPIConnectionSettingsStore.save( - artifactDisposition: .purge, - store: { - didStore = true - return false - }, - purgeArtifacts: { - didPurge = true - return true - }, - clearDisconnectedState: { true }, - rollback: { true }) + func `replacement keeps prior telemetry when credential storage fails`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-replacement-store-failure-\(UUID().uuidString)", isDirectory: true) + let costUsage = root.appendingPathComponent("cost-usage", isDirectory: true) + let usageFile = costUsage.appendingPathComponent(CostUsageCacheLocations.cliProxyAPIUsageFileName) + try fileManager.createDirectory(at: costUsage, withIntermediateDirectories: true) + try Data("telemetry".utf8).write(to: usageFile) + defer { try? fileManager.removeItem(at: root) } + let existing = CLIProxyAPIConnectionSettings(managementKey: "old-management-key") + let replacement = CLIProxyAPIConnectionSettings(managementKey: "new-management-key") + + let saved = CLIProxyAPIConnectionSettingsStore.saveSerialized( + replacement, + artifactDirectories: [costUsage], + stateRoot: root, + fileManager: fileManager, + operations: .init( + isDisconnected: { false }, + loadStored: { .found(existing) }, + store: { _ in false }, + setDisconnectedState: { _ in true }, + restore: { _ in true })) #expect(!saved) - #expect(didStore) - #expect(!didPurge) + #expect(fileManager.fileExists(atPath: usageFile.path)) } @Test - func `replacement restores prior credentials when telemetry purge fails`() { - var operations: [String] = [] - - let saved = CLIProxyAPIConnectionSettingsStore.save( - artifactDisposition: .purge, - store: { - operations.append("store") - return true - }, - purgeArtifacts: { - operations.append("purge") - return false - }, - clearDisconnectedState: { - operations.append("clear") - return true - }, - rollback: { - operations.append("restore") - return true + func `failed replacement staging restores already moved telemetry`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-replacement-stage-failure-\(UUID().uuidString)", isDirectory: true) + let firstDirectory = root.appendingPathComponent("first/cost-usage", isDirectory: true) + let secondDirectory = root.appendingPathComponent("second/cost-usage", isDirectory: true) + let firstURL = firstDirectory.appendingPathComponent(CostUsageCacheLocations.cliProxyAPIUsageFileName) + let secondURL = secondDirectory.appendingPathComponent(CostUsageCacheLocations.cliProxyAPIUsageFileName) + for url in [firstURL, secondURL] { + try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("telemetry".utf8).write(to: url) + } + defer { try? fileManager.removeItem(at: root) } + + let update = CostUsageCacheLocations.prepareCLIProxyAPIArtifactsUpdate( + in: [firstDirectory, secondDirectory], + fileExists: { fileManager.fileExists(atPath: $0.path) }, + moveItem: { source, destination in + if source == secondURL { + throw CocoaError(.fileWriteUnknown) + } + try fileManager.moveItem(at: source, to: destination) }) - #expect(!saved) - #expect(operations == ["store", "purge", "restore"]) + #expect(update == nil) + #expect(fileManager.fileExists(atPath: firstURL.path)) + #expect(fileManager.fileExists(atPath: secondURL.path)) } @Test @@ -277,8 +297,7 @@ struct CLIProxyAPIUsageCacheTests { isDisconnected: { false }, loadStored: { .missing }, store: { _ in true }, - purgeArtifacts: { true }, - clearDisconnectedState: { true }, + setDisconnectedState: { _ in true }, restore: { _ in true }))) let savedGeneration = try #require(CostUsageCacheLocations.cliProxyAPIConfigurationGeneration( stateRoot: root, @@ -288,7 +307,12 @@ struct CLIProxyAPIUsageCacheTests { in: [], stateRoot: root, fileManager: fileManager, - clearConfiguration: { true }) == .removed) + operations: .init( + isDisconnected: { false }, + loadStored: { .missing }, + clearConfiguration: { true }, + setDisconnectedState: { _ in true }, + restore: { _ in true })) == .removed) let removedGeneration = try #require(CostUsageCacheLocations.cliProxyAPIConfigurationGeneration( stateRoot: root, fileManager: fileManager)) @@ -311,14 +335,108 @@ struct CLIProxyAPIUsageCacheTests { isDisconnected: { false }, loadStored: { .missing }, store: { _ in false }, - purgeArtifacts: { true }, - clearDisconnectedState: { true }, + setDisconnectedState: { _ in true }, restore: { _ in true }))) #expect(CostUsageCacheLocations.cliProxyAPIConfigurationGeneration( stateRoot: root, fileManager: fileManager) == nil) } + @Test + func `generation publication failure restores replacement credentials state and telemetry`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-generation-commit-failure-\(UUID().uuidString)", isDirectory: true) + let costUsage = root.appendingPathComponent("cost-usage", isDirectory: true) + let usageFile = costUsage.appendingPathComponent(CostUsageCacheLocations.cliProxyAPIUsageFileName) + let generationURL = root.appendingPathComponent( + "cliproxyapi-configuration-generation-v1", + isDirectory: false) + try fileManager.createDirectory(at: costUsage, withIntermediateDirectories: true) + try Data("telemetry".utf8).write(to: usageFile) + defer { try? fileManager.removeItem(at: root) } + let existing = CLIProxyAPIConnectionSettings(managementKey: "old-management-key") + let replacement = CLIProxyAPIConnectionSettings(managementKey: "new-management-key") + let storedSettings = LockIsolated(existing) + let disconnected = LockIsolated(true) + + let saved = CLIProxyAPIConnectionSettingsStore.saveSerialized( + replacement, + artifactDirectories: [costUsage], + stateRoot: root, + fileManager: fileManager, + operations: .init( + isDisconnected: { disconnected.value }, + loadStored: { .found(storedSettings.value) }, + store: { settings in + storedSettings.setValue(settings) + return true + }, + setDisconnectedState: { value in + disconnected.setValue(value) + if !value { + try? FileManager.default.createDirectory(at: generationURL, withIntermediateDirectories: true) + } + return true + }, + restore: { snapshot in + guard case let .found(settings) = snapshot else { return false } + storedSettings.setValue(settings) + return true + })) + + #expect(!saved) + #expect(storedSettings.value == existing) + #expect(disconnected.value) + #expect(fileManager.fileExists(atPath: usageFile.path)) + } + + @Test + func `generation publication failure rolls back configuration removal and telemetry`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-removal-generation-failure-\(UUID().uuidString)", isDirectory: true) + let costUsage = root.appendingPathComponent("cost-usage", isDirectory: true) + let usageFile = costUsage.appendingPathComponent(CostUsageCacheLocations.cliProxyAPIUsageFileName) + let generationURL = root.appendingPathComponent( + "cliproxyapi-configuration-generation-v1", + isDirectory: false) + try fileManager.createDirectory(at: costUsage, withIntermediateDirectories: true) + try Data("telemetry".utf8).write(to: usageFile) + defer { try? fileManager.removeItem(at: root) } + let existing = CLIProxyAPIConnectionSettings(managementKey: "old-management-key") + let storedSettings = LockIsolated(existing) + let disconnected = LockIsolated(false) + + let result = CLIProxyAPIConnectionSettingsStore.removeAndPurgeTelemetry( + in: [costUsage], + stateRoot: root, + fileManager: fileManager, + operations: .init( + isDisconnected: { disconnected.value }, + loadStored: { .found(existing) }, + clearConfiguration: { + storedSettings.setValue(nil) + disconnected.setValue(true) + try? FileManager.default.createDirectory(at: generationURL, withIntermediateDirectories: true) + return true + }, + setDisconnectedState: { value in + disconnected.setValue(value) + return true + }, + restore: { snapshot in + guard case let .found(settings) = snapshot else { return false } + storedSettings.setValue(settings) + return true + })) + + #expect(result == .configurationRemovalFailed) + #expect(storedSettings.value == existing) + #expect(!disconnected.value) + #expect(fileManager.fileExists(atPath: usageFile.path)) + } + @Test func `configuration removal waits for an in progress save transaction`() async throws { let fileManager = FileManager.default @@ -347,8 +465,7 @@ struct CLIProxyAPIUsageCacheTests { releaseSave.wait() return true }, - purgeArtifacts: { true }, - clearDisconnectedState: { true }, + setDisconnectedState: { _ in true }, restore: { _ in true })) } #expect(await Self.waitForSignal(saveEntered, timeout: .now() + 1)) @@ -358,10 +475,15 @@ struct CLIProxyAPIUsageCacheTests { in: [costUsage], stateRoot: root, fileManager: .default, - clearConfiguration: { - removalEntered.signal() - return true - }) + operations: .init( + isDisconnected: { false }, + loadStored: { .missing }, + clearConfiguration: { + removalEntered.signal() + return true + }, + setDisconnectedState: { _ in true }, + restore: { _ in true })) } let removalEnteredBeforeSaveFinished = await Self.waitForSignal( removalEntered, diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift index 6a8f622a24..d32a9686f3 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift @@ -338,6 +338,41 @@ struct CLIProxyAPIUsageStoreTests { #expect(refreshes.map(\.1) == [true, true]) } + @Test + func `failed collection invalidates stale snapshots after configuration changes`() async { + let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") + settings.costUsageEnabled = true + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + let snapshot = Self.tokenSnapshot() + store.publishTokenSnapshot(snapshot, for: .codex) + store.publishTokenSnapshot(snapshot, for: .claude) + + var collectorState = await store.handleCLIProxyAPIUsageCollectionResult( + .failed("replacement endpoint unavailable"), + collectorState: CLIProxyAPIUsageCollectorState( + configurationAvailability: .available, + configurationGeneration: "before-replacement"), + configurationGeneration: { "after-replacement" }) + + #expect(collectorState.configurationAvailability == .available) + #expect(collectorState.configurationGeneration == "after-replacement") + #expect(store.tokenSnapshot(for: .codex) == nil) + #expect(store.tokenSnapshot(for: .claude) == nil) + + store.publishTokenSnapshot(snapshot, for: .codex) + collectorState = await store.handleCLIProxyAPIUsageCollectionResult( + .failed("still unavailable"), + collectorState: collectorState, + configurationGeneration: { "after-replacement" }) + + #expect(store.tokenSnapshot(for: .codex) == snapshot) + } + @Test func `clearing cost cache drains the active proxy collector before deletion`() async { let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") From 146e6c2278920a84450039299e99caeb8a3e28ec Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 3 Aug 2026 05:07:06 -0700 Subject: [PATCH 090/183] Harden proxy replacement interruptions --- .../CLIProxyAPIUsageTelemetry.swift | 4 + .../CostUsageCacheLocations.swift | 135 ++++++++++++++++-- .../CostUsage/CostUsageScanner+Claude.swift | 12 +- .../CLIProxyAPIUsageCacheTests.swift | 62 ++++++++ ...ageFetcherCachedProxyDisconnectTests.swift | 56 ++++++++ 5 files changed, 260 insertions(+), 9 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 8d7f60c28c..e89d4a4f1e 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -748,6 +748,8 @@ public enum CLIProxyAPIConnectionSettingsStore { case .purge: guard let update = CostUsageCacheLocations.prepareCLIProxyAPIArtifactsUpdate( in: artifactDirectories, + stateRoot: stateRoot, + expectedGeneration: generationUpdate.generation, fileManager: fileManager) else { return false } artifactsUpdate = update @@ -876,6 +878,8 @@ public enum CLIProxyAPIConnectionSettingsStore { } guard let artifactsUpdate = CostUsageCacheLocations.prepareCLIProxyAPIArtifactsUpdate( in: directories, + stateRoot: stateRoot, + expectedGeneration: generationUpdate.generation, fileManager: fileManager) else { return .configurationRemovalFailed } diff --git a/Sources/CodexBarCore/CostUsageCacheLocations.swift b/Sources/CodexBarCore/CostUsageCacheLocations.swift index 546a3b6724..80db00712d 100644 --- a/Sources/CodexBarCore/CostUsageCacheLocations.swift +++ b/Sources/CodexBarCore/CostUsageCacheLocations.swift @@ -20,17 +20,35 @@ public enum CostUsageCacheLocations { } let moves: [Move] + let manifestURL: URL? + + init(moves: [Move], manifestURL: URL? = nil) { + self.moves = moves + self.manifestURL = manifestURL + } } struct CLIProxyAPIConfigurationGenerationUpdate { let stagedURL: URL let destinationURL: URL + let generation: String + } + + private struct CLIProxyAPIArtifactsTransactionManifest: Codable { + struct Move: Codable { + let originalPath: String + let stagedPath: String + } + + let expectedGeneration: String + let moves: [Move] } static let cliProxyAPIUsageFileName = "cliproxyapi-usage-v1.json" static let cliProxyAPIPendingFileName = "cliproxyapi-pending-v1.json" private static let cliProxyAPIDisconnectedFileName = "cliproxyapi-disconnected-v1" private static let cliProxyAPIConfigurationGenerationFileName = "cliproxyapi-configuration-generation-v1" + private static let cliProxyAPIArtifactsTransactionFileName = "cliproxyapi-artifacts-transaction-v1.json" public static func directories(fileManager: FileManager = .default) -> [URL] { let cacheRoot = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! @@ -88,6 +106,9 @@ public enum CostUsageCacheLocations { { let descriptor = try self.acquireCLIProxyAPILock(stateRoot: stateRoot, fileManager: fileManager) defer { self.releaseCLIProxyAPILock(descriptor) } + guard self.recoverCLIProxyAPIArtifactsTransaction(stateRoot: stateRoot, fileManager: fileManager) else { + throw CocoaError(.fileReadUnknown) + } return try operation() } @@ -98,6 +119,9 @@ public enum CostUsageCacheLocations { { let descriptor = try self.acquireCLIProxyAPILock(stateRoot: stateRoot, fileManager: fileManager) defer { self.releaseCLIProxyAPILock(descriptor) } + guard self.recoverCLIProxyAPIArtifactsTransaction(stateRoot: stateRoot, fileManager: fileManager) else { + throw CocoaError(.fileReadUnknown) + } return try await operation() } @@ -148,12 +172,51 @@ public enum CostUsageCacheLocations { static func prepareCLIProxyAPIArtifactsUpdate( in directories: [URL], + stateRoot: URL?, + expectedGeneration: String, fileManager: FileManager) -> CLIProxyAPIArtifactsUpdate? { - self.prepareCLIProxyAPIArtifactsUpdate( - in: directories, - fileExists: { fileManager.fileExists(atPath: $0.path) }, - moveItem: { try fileManager.moveItem(at: $0, to: $1) }) + let identifier = UUID().uuidString + let moves = self.cliProxyAPIArtifactURLs(in: directories) + .filter { fileManager.fileExists(atPath: $0.path) } + .map { originalURL in + CLIProxyAPIArtifactsUpdate.Move( + originalURL: originalURL, + stagedURL: originalURL + .deletingLastPathComponent() + .appendingPathComponent( + ".\(originalURL.lastPathComponent).\(identifier).replacement-backup", + isDirectory: false)) + } + guard !moves.isEmpty else { return CLIProxyAPIArtifactsUpdate(moves: []) } + + let manifestURL = self.cliProxyAPIArtifactsTransactionURL( + stateRoot: stateRoot, + fileManager: fileManager) + let manifest = CLIProxyAPIArtifactsTransactionManifest( + expectedGeneration: expectedGeneration, + moves: moves.map { + .init(originalPath: $0.originalURL.path, stagedPath: $0.stagedURL.path) + }) + do { + try fileManager.createDirectory( + at: manifestURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + try JSONEncoder().encode(manifest).write(to: manifestURL, options: [.atomic]) + } catch { + return nil + } + + let update = CLIProxyAPIArtifactsUpdate(moves: moves, manifestURL: manifestURL) + for move in moves { + do { + try fileManager.moveItem(at: move.originalURL, to: move.stagedURL) + } catch { + _ = self.restoreCLIProxyAPIArtifactsUpdate(update, fileManager: fileManager) + return nil + } + } + return update } static func prepareCLIProxyAPIArtifactsUpdate( @@ -188,10 +251,12 @@ public enum CostUsageCacheLocations { _ update: CLIProxyAPIArtifactsUpdate, fileManager: FileManager) -> Bool { - self.restoreCLIProxyAPIArtifactsUpdate( + let restored = self.restoreCLIProxyAPIArtifactsUpdate( update, fileExists: { fileManager.fileExists(atPath: $0.path) }, moveItem: { try fileManager.moveItem(at: $0, to: $1) }) + guard restored else { return false } + return self.removeCLIProxyAPIArtifactsManifest(update.manifestURL, fileManager: fileManager) } @discardableResult @@ -229,7 +294,48 @@ public enum CostUsageCacheLocations { succeeded = false } } - return succeeded + guard succeeded else { return false } + return self.removeCLIProxyAPIArtifactsManifest(update.manifestURL, fileManager: fileManager) + } + + @discardableResult + static func recoverCLIProxyAPIArtifactsTransaction( + stateRoot: URL?, + fileManager: FileManager = .default) -> Bool + { + let manifestURL = self.cliProxyAPIArtifactsTransactionURL( + stateRoot: stateRoot, + fileManager: fileManager) + guard fileManager.fileExists(atPath: manifestURL.path) else { return true } + guard let data = try? Data(contentsOf: manifestURL), + let manifest = try? JSONDecoder().decode(CLIProxyAPIArtifactsTransactionManifest.self, from: data) + else { return false } + let update = CLIProxyAPIArtifactsUpdate( + moves: manifest.moves.map { + .init( + originalURL: URL(fileURLWithPath: $0.originalPath), + stagedURL: URL(fileURLWithPath: $0.stagedPath)) + }, + manifestURL: manifestURL) + if self.cliProxyAPIConfigurationGeneration(stateRoot: stateRoot, fileManager: fileManager) == + manifest.expectedGeneration + { + return self.discardCLIProxyAPIArtifactsUpdate(update, fileManager: fileManager) + } + return self.restoreCLIProxyAPIArtifactsUpdate(update, fileManager: fileManager) + } + + private static func removeCLIProxyAPIArtifactsManifest( + _ manifestURL: URL?, + fileManager: FileManager) -> Bool + { + guard let manifestURL, fileManager.fileExists(atPath: manifestURL.path) else { return true } + do { + try fileManager.removeItem(at: manifestURL) + return true + } catch { + return false + } } private static func cliProxyAPIArtifactURLs(in directories: [URL]) -> [URL] { @@ -278,14 +384,16 @@ public enum CostUsageCacheLocations { let stagedURL = destinationURL .deletingLastPathComponent() .appendingPathComponent(".cliproxyapi-generation-\(UUID().uuidString).tmp", isDirectory: false) + let generation = UUID().uuidString do { try fileManager.createDirectory( at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true) - try Data(UUID().uuidString.utf8).write(to: stagedURL, options: [.atomic]) + try Data(generation.utf8).write(to: stagedURL, options: [.atomic]) return CLIProxyAPIConfigurationGenerationUpdate( stagedURL: stagedURL, - destinationURL: destinationURL) + destinationURL: destinationURL, + generation: generation) } catch { try? fileManager.removeItem(at: stagedURL) return nil @@ -361,6 +469,17 @@ public enum CostUsageCacheLocations { isDirectory: false) } + private static func cliProxyAPIArtifactsTransactionURL( + stateRoot: URL?, + fileManager: FileManager) -> URL + { + let root = stateRoot ?? fileManager.urls( + for: .applicationSupportDirectory, + in: .userDomainMask).first! + .appendingPathComponent("CodexBar", isDirectory: true) + return root.appendingPathComponent(self.cliProxyAPIArtifactsTransactionFileName, isDirectory: false) + } + private static func acquireCLIProxyAPILock( stateRoot: URL?, fileManager: FileManager) throws -> Int32 diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index e6e6559341..b01717f411 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -892,6 +892,9 @@ extension CostUsageScanner { options: Options, checkCancellation: CancellationCheck?) throws -> CostUsageDailyReport { + try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock(stateRoot: options.cacheRoot) {} + let cliProxyAPIConfigurationGeneration = CostUsageCacheLocations + .cliProxyAPIConfigurationGeneration(stateRoot: options.cacheRoot) var cache = CostUsageCacheIO.load( provider: provider, cacheRoot: options.cacheRoot, @@ -975,8 +978,11 @@ extension CostUsageScanner { cache.lastScanUnixMs = nowMs try checkCancellation?() try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( - stateRoot: options.cacheRoot?.deletingLastPathComponent()) + stateRoot: options.cacheRoot) { + guard CostUsageCacheLocations.cliProxyAPIConfigurationGeneration( + stateRoot: options.cacheRoot) == cliProxyAPIConfigurationGeneration + else { throw CancellationError() } if CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( stateRoot: options.cacheRoot) { @@ -1013,6 +1019,10 @@ extension CostUsageScanner { } } + guard CostUsageCacheLocations.cliProxyAPIConfigurationGeneration( + stateRoot: options.cacheRoot) == cliProxyAPIConfigurationGeneration + else { throw CancellationError() } + let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: options.cacheRoot) let reportAttributionEnabled = !CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( stateRoot: options.cacheRoot) diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index 1a3c39f00c..fba48c3295 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -831,3 +831,65 @@ struct CLIProxyAPIUsageCacheTests { } } } + +struct CLIProxyAPITransactionRecoveryTests { + @Test + func `interrupted uncommitted replacement restores staged artifacts on the next lock`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-replacement-recovery-\(UUID().uuidString)", isDirectory: true) + let costUsage = root.appendingPathComponent("cost-usage", isDirectory: true) + let usageFile = costUsage.appendingPathComponent(CostUsageCacheLocations.cliProxyAPIUsageFileName) + try fileManager.createDirectory(at: costUsage, withIntermediateDirectories: true) + try Data("telemetry".utf8).write(to: usageFile) + defer { try? fileManager.removeItem(at: root) } + + let generationUpdate = try #require(CostUsageCacheLocations + .prepareCLIProxyAPIConfigurationGenerationUpdate(stateRoot: root, fileManager: fileManager)) + let artifactsUpdate = try #require(CostUsageCacheLocations.prepareCLIProxyAPIArtifactsUpdate( + in: [costUsage], + stateRoot: root, + expectedGeneration: generationUpdate.generation, + fileManager: fileManager)) + #expect(!fileManager.fileExists(atPath: usageFile.path)) + #expect(artifactsUpdate.manifestURL.map { fileManager.fileExists(atPath: $0.path) } == true) + + try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock(stateRoot: root, fileManager: fileManager) {} + + #expect(fileManager.fileExists(atPath: usageFile.path)) + #expect(artifactsUpdate.moves.allSatisfy { !fileManager.fileExists(atPath: $0.stagedURL.path) }) + #expect(artifactsUpdate.manifestURL.map { !fileManager.fileExists(atPath: $0.path) } == true) + CostUsageCacheLocations.discardCLIProxyAPIConfigurationGenerationUpdate( + generationUpdate, + fileManager: fileManager) + } + + @Test + func `interrupted committed replacement discards staged artifacts on the next lock`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-replacement-finalize-\(UUID().uuidString)", isDirectory: true) + let costUsage = root.appendingPathComponent("cost-usage", isDirectory: true) + let usageFile = costUsage.appendingPathComponent(CostUsageCacheLocations.cliProxyAPIUsageFileName) + try fileManager.createDirectory(at: costUsage, withIntermediateDirectories: true) + try Data("telemetry".utf8).write(to: usageFile) + defer { try? fileManager.removeItem(at: root) } + + let generationUpdate = try #require(CostUsageCacheLocations + .prepareCLIProxyAPIConfigurationGenerationUpdate(stateRoot: root, fileManager: fileManager)) + let artifactsUpdate = try #require(CostUsageCacheLocations.prepareCLIProxyAPIArtifactsUpdate( + in: [costUsage], + stateRoot: root, + expectedGeneration: generationUpdate.generation, + fileManager: fileManager)) + #expect(CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate( + generationUpdate, + fileManager: fileManager)) + + try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock(stateRoot: root, fileManager: fileManager) {} + + #expect(!fileManager.fileExists(atPath: usageFile.path)) + #expect(artifactsUpdate.moves.allSatisfy { !fileManager.fileExists(atPath: $0.stagedURL.path) }) + #expect(artifactsUpdate.manifestURL.map { !fileManager.fileExists(atPath: $0.path) } == true) + } +} diff --git a/Tests/CodexBarTests/CostUsageFetcherCachedProxyDisconnectTests.swift b/Tests/CodexBarTests/CostUsageFetcherCachedProxyDisconnectTests.swift index 0bbe65a172..196ccbf5ca 100644 --- a/Tests/CodexBarTests/CostUsageFetcherCachedProxyDisconnectTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherCachedProxyDisconnectTests.swift @@ -3,6 +3,62 @@ import Testing @testable import CodexBarCore struct CostUsageFetcherCachedProxyDisconnectTests { + @Test + func `configuration replacement rejects an in flight Claude cache publication`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 24) + _ = try env.writeClaudeProjectFile( + relativePath: "generation-race/session.jsonl", + contents: env.jsonl([[ + "type": "assistant", + "timestamp": env.isoString(for: day), + "sessionId": "session-generation-race", + "requestId": "request-generation-race", + "message": [ + "id": "message-generation-race", + "model": "claude-sonnet-4-6", + "usage": ["input_tokens": 100, "output_tokens": 5], + ], + ]])) + + let initialGeneration = try #require(CostUsageCacheLocations + .prepareCLIProxyAPIConfigurationGenerationUpdate( + stateRoot: env.cacheRoot, + fileManager: .default)) + #expect(CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate(initialGeneration)) + + var options = CostUsageScanner.Options( + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot) + options.forceRescan = true + var replacedConfiguration = false + + #expect(throws: CancellationError.self) { + _ = try CostUsageScanner.loadClaudeDaily( + provider: .claude, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + now: day, + options: options, + checkCancellation: { + guard !replacedConfiguration else { return } + replacedConfiguration = true + let replacementGeneration = try #require(CostUsageCacheLocations + .prepareCLIProxyAPIConfigurationGenerationUpdate( + stateRoot: env.cacheRoot, + fileManager: .default)) + #expect(CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate( + replacementGeneration)) + }) + } + #expect(replacedConfiguration) + #expect(CostUsageCacheIO.load( + provider: .claude, + cacheRoot: env.cacheRoot, + calendar: options.calendar).lastScanUnixMs == 0) + } + @Test func `disconnect during proxy scan excludes the stale Codex report`() throws { let env = try CostUsageTestEnvironment() From 340ec7ab36f5e2645314113a959494d00a1fa813 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 3 Aug 2026 05:31:31 -0700 Subject: [PATCH 091/183] Guard proxy report generation --- .../CostUsage/CostUsageScanner+Claude.swift | 47 ++++++++++-------- ...ageFetcherCachedProxyDisconnectTests.swift | 48 +++++++++++++++++++ 2 files changed, 76 insertions(+), 19 deletions(-) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index b01717f411..9f95db453a 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -1019,29 +1019,38 @@ extension CostUsageScanner { } } - guard CostUsageCacheLocations.cliProxyAPIConfigurationGeneration( - stateRoot: options.cacheRoot) == cliProxyAPIConfigurationGeneration - else { throw CancellationError() } - let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: options.cacheRoot) - let reportAttributionEnabled = !CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( + return try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( stateRoot: options.cacheRoot) - let reportAttributionFilter: ClaudeAttributionFilter = if reportAttributionEnabled { - options.claudeAttributionFilter - } else { - switch options.claudeAttributionFilter { - case .all, .excludeCodexBackend: .all - case .codexBackendOnly: .codexBackendOnly + { + guard CostUsageCacheLocations.cliProxyAPIConfigurationGeneration( + stateRoot: options.cacheRoot) == cliProxyAPIConfigurationGeneration + else { throw CancellationError() } + + let reportAttributionEnabled = !CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( + stateRoot: options.cacheRoot) + let reportAttributionFilter: ClaudeAttributionFilter = if reportAttributionEnabled { + options.claudeAttributionFilter + } else { + switch options.claudeAttributionFilter { + case .all, .excludeCodexBackend: .all + case .codexBackendOnly: .codexBackendOnly + } } + let report = Self.buildClaudeReportFromCache( + cache: cache, + range: range, + attributionFilter: reportAttributionFilter, + attributionResolver: reportAttributionEnabled ? attributionResolver : nil, + allowCachedCLIProxyAPIAttribution: reportAttributionEnabled, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: options.cacheRoot) + try checkCancellation?() + guard CostUsageCacheLocations.cliProxyAPIConfigurationGeneration( + stateRoot: options.cacheRoot) == cliProxyAPIConfigurationGeneration + else { throw CancellationError() } + return report } - return Self.buildClaudeReportFromCache( - cache: cache, - range: range, - attributionFilter: reportAttributionFilter, - attributionResolver: reportAttributionEnabled ? attributionResolver : nil, - allowCachedCLIProxyAPIAttribution: reportAttributionEnabled, - modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: options.cacheRoot) } private struct ClaudeReportAggregation { diff --git a/Tests/CodexBarTests/CostUsageFetcherCachedProxyDisconnectTests.swift b/Tests/CodexBarTests/CostUsageFetcherCachedProxyDisconnectTests.swift index 196ccbf5ca..b286d21b07 100644 --- a/Tests/CodexBarTests/CostUsageFetcherCachedProxyDisconnectTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherCachedProxyDisconnectTests.swift @@ -59,6 +59,54 @@ struct CostUsageFetcherCachedProxyDisconnectTests { calendar: options.calendar).lastScanUnixMs == 0) } + @Test + func `configuration replacement rejects a report built from the previous generation`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 24) + let calendar = Calendar(identifier: .gregorian) + var cache = CostUsageCache() + cache.lastScanUnixMs = Int64(day.timeIntervalSince1970 * 1000) + cache.scanSinceKey = "2026-07-24" + cache.scanUntilKey = "2026-07-24" + cache.days = ["2026-07-24": ["claude-sonnet-4-6": [100, 0, 0, 5, 0, 1]]] + CostUsageCacheIO.save( + provider: .claude, + cache: cache, + cacheRoot: env.cacheRoot, + calendar: calendar) + + let initialGeneration = try #require(CostUsageCacheLocations + .prepareCLIProxyAPIConfigurationGenerationUpdate( + stateRoot: env.cacheRoot, + fileManager: .default)) + #expect(CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate(initialGeneration)) + + var options = CostUsageScanner.Options(cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 3600 + var replacedConfiguration = false + + #expect(throws: CancellationError.self) { + _ = try CostUsageScanner.loadClaudeDaily( + provider: .claude, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + now: day, + options: options, + checkCancellation: { + guard !replacedConfiguration else { return } + replacedConfiguration = true + let replacementGeneration = try #require(CostUsageCacheLocations + .prepareCLIProxyAPIConfigurationGenerationUpdate( + stateRoot: env.cacheRoot, + fileManager: .default)) + #expect(CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate( + replacementGeneration)) + }) + } + #expect(replacedConfiguration) + } + @Test func `disconnect during proxy scan excludes the stale Codex report`() throws { let env = try CostUsageTestEnvironment() From 2daf0309a0d8289434020d6e07bc53a7c7260d79 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 3 Aug 2026 05:55:14 -0700 Subject: [PATCH 092/183] Invalidate scans when clearing cost caches --- .../CostUsageCacheLocations.swift | 29 ++++++++++++++++- .../CLIProxyAPIUsageCacheTests.swift | 31 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/CostUsageCacheLocations.swift b/Sources/CodexBarCore/CostUsageCacheLocations.swift index 80db00712d..35097fb88e 100644 --- a/Sources/CodexBarCore/CostUsageCacheLocations.swift +++ b/Sources/CodexBarCore/CostUsageCacheLocations.swift @@ -81,6 +81,14 @@ public enum CostUsageCacheLocations { stateRoot: stateRoot, fileManager: fileManager) { + guard self.advanceCLIProxyAPIConfigurationGeneration( + stateRoot: stateRoot, + fileManager: fileManager) + else { + return CostUsageCacheClearResult( + cleared: 0, + errorDescription: CocoaError(.fileWriteUnknown).localizedDescription) + } var cleared = 0 for directory in directories where fileManager.fileExists(atPath: directory.path) { do { @@ -145,7 +153,11 @@ public enum CostUsageCacheLocations { stateRoot: stateRoot, fileManager: fileManager) { - self.clearCLIProxyAPIArtifactsUnserialized( + guard self.advanceCLIProxyAPIConfigurationGeneration( + stateRoot: stateRoot, + fileManager: fileManager) + else { return false } + return self.clearCLIProxyAPIArtifactsUnserialized( in: directories, fileManager: fileManager) } @@ -414,6 +426,21 @@ public enum CostUsageCacheLocations { try? fileManager.removeItem(at: update.stagedURL) } + private static func advanceCLIProxyAPIConfigurationGeneration( + stateRoot: URL?, + fileManager: FileManager) -> Bool + { + guard let update = self.prepareCLIProxyAPIConfigurationGenerationUpdate( + stateRoot: stateRoot, + fileManager: fileManager) + else { return false } + guard self.commitCLIProxyAPIConfigurationGenerationUpdate(update, fileManager: fileManager) else { + self.discardCLIProxyAPIConfigurationGenerationUpdate(update, fileManager: fileManager) + return false + } + return true + } + @discardableResult static func setCLIProxyAPIExplicitlyDisconnected( _ disconnected: Bool, diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index fba48c3295..c72f37f4cc 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -3,6 +3,37 @@ import Testing @testable import CodexBarCore struct CLIProxyAPIUsageCacheTests { + @Test + func `cost cache clear advances the durable generation for other processes`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-clear-generation-\(UUID().uuidString)", isDirectory: true) + let costUsage = root.appendingPathComponent("cost-usage", isDirectory: true) + defer { try? fileManager.removeItem(at: root) } + + try fileManager.createDirectory(at: costUsage, withIntermediateDirectories: true) + try Data("cache".utf8).write(to: costUsage.appendingPathComponent("claude-v6.json")) + let initialUpdate = try #require(CostUsageCacheLocations.prepareCLIProxyAPIConfigurationGenerationUpdate( + stateRoot: root, + fileManager: fileManager)) + #expect(CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate( + initialUpdate, + fileManager: fileManager)) + let initialGeneration = CostUsageCacheLocations.cliProxyAPIConfigurationGeneration( + stateRoot: root, + fileManager: fileManager) + + let result = CostUsageCacheLocations.clearAllCostUsageCaches( + in: [costUsage], + stateRoot: root, + fileManager: fileManager) + + #expect(result == CostUsageCacheClearResult(cleared: 1, errorDescription: nil)) + #expect(CostUsageCacheLocations.cliProxyAPIConfigurationGeneration( + stateRoot: root, + fileManager: fileManager) != initialGeneration) + } + @Test func `integration cleanup removes telemetry pending and derived Claude cache artifacts`() throws { let fileManager = FileManager.default From 7a6e3fd59af3a04acd45a183860adb067910202d Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 3 Aug 2026 06:17:48 -0700 Subject: [PATCH 093/183] Harden proxy generation boundaries --- Sources/CodexBar/UsageStore+TokenCost.swift | 2 ++ .../CLIProxyAPIUsageTelemetry.swift | 15 ++++---- Sources/CodexBarCore/CostUsageFetcher.swift | 8 +++++ .../CLIProxyAPIUsageCacheTests.swift | 13 +++---- .../UsageStoreCachedTokenHydrationTests.swift | 35 +++++++++++++++++++ 5 files changed, 61 insertions(+), 12 deletions(-) diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 490a18618c..ad736162a4 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -240,6 +240,7 @@ extension UsageStore { let costUsageSettingsRevision = self.settings.costUsageSettingsRevision let tokenSnapshotScopeSignature = self.tokenSnapshotScopeSignature(for: .codex) let tokenSnapshotPublicationRevision = self.tokenSnapshotPublicationRevision(for: .codex) + let cliProxyAPIConfigurationGeneration = self.costUsageFetcher.cliProxyAPIConfigurationGeneration() return Task { @MainActor [weak self] in guard let self else { return } guard self.tokenSnapshotPublicationForCurrentProviderConfig(for: .codex) == nil else { return } @@ -274,6 +275,7 @@ extension UsageStore { self.settings.costUsageHistoryDays == historyDays, self.tokenSnapshotScopeSignature(for: .codex) == tokenSnapshotScopeSignature, self.tokenSnapshotPublicationRevision(for: .codex) == tokenSnapshotPublicationRevision, + self.costUsageFetcher.cliProxyAPIConfigurationGeneration() == cliProxyAPIConfigurationGeneration, self.tokenSnapshotPublicationForCurrentProviderConfig(for: .codex) == nil else { return diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index e89d4a4f1e..83058ef8f1 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -765,18 +765,21 @@ public enum CLIProxyAPIConnectionSettingsStore { } } - guard operations.store(settings) else { + // Publish the new generation before mutating credentials. If the process exits after + // the credential write, recovery will see the committed generation and finalize the + // staged purge instead of restoring artifacts from the previous configuration. + guard CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate( + generationUpdate, + fileManager: fileManager) + else { rollback() return false } - guard operations.setDisconnectedState(false) else { + guard operations.store(settings) else { rollback() return false } - guard CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate( - generationUpdate, - fileManager: fileManager) - else { + guard operations.setDisconnectedState(false) else { rollback() return false } diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 1bc34dce3a..765de025c3 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -266,6 +266,14 @@ public struct CostUsageFetcher: Sendable { self.scannerOptions } + package func cliProxyAPIConfigurationGeneration() -> String? { + let options = Self.resolvedScannerOptions( + self.scannerOptionsOverride(), + provider: .codex, + codexHomePath: nil) + return CostUsageCacheLocations.cliProxyAPIConfigurationGeneration(stateRoot: options.cacheRoot) + } + package func codexScanCatchUpStatus( codexHomePath: String? = nil) async -> CodexScanCatchUpStatus { diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index c72f37f4cc..d767f40aca 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -352,7 +352,7 @@ struct CLIProxyAPIUsageCacheTests { } @Test - func `failed save does not publish its staged configuration generation`() { + func `failed save still invalidates in flight work before credential mutation`() { let fileManager = FileManager.default let root = fileManager.temporaryDirectory .appendingPathComponent("cliproxy-generation-failure-\(UUID().uuidString)", isDirectory: true) @@ -370,11 +370,11 @@ struct CLIProxyAPIUsageCacheTests { restore: { _ in true }))) #expect(CostUsageCacheLocations.cliProxyAPIConfigurationGeneration( stateRoot: root, - fileManager: fileManager) == nil) + fileManager: fileManager) != nil) } @Test - func `generation publication failure restores replacement credentials state and telemetry`() throws { + func `generation publication failure prevents replacement credential mutation and restores telemetry`() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory .appendingPathComponent("cliproxy-generation-commit-failure-\(UUID().uuidString)", isDirectory: true) @@ -390,6 +390,8 @@ struct CLIProxyAPIUsageCacheTests { let replacement = CLIProxyAPIConnectionSettings(managementKey: "new-management-key") let storedSettings = LockIsolated(existing) let disconnected = LockIsolated(true) + let didStore = LockIsolated(false) + try fileManager.createDirectory(at: generationURL, withIntermediateDirectories: true) let saved = CLIProxyAPIConnectionSettingsStore.saveSerialized( replacement, @@ -400,14 +402,12 @@ struct CLIProxyAPIUsageCacheTests { isDisconnected: { disconnected.value }, loadStored: { .found(storedSettings.value) }, store: { settings in + didStore.setValue(true) storedSettings.setValue(settings) return true }, setDisconnectedState: { value in disconnected.setValue(value) - if !value { - try? FileManager.default.createDirectory(at: generationURL, withIntermediateDirectories: true) - } return true }, restore: { snapshot in @@ -417,6 +417,7 @@ struct CLIProxyAPIUsageCacheTests { })) #expect(!saved) + #expect(!didStore.value) #expect(storedSettings.value == existing) #expect(disconnected.value) #expect(fileManager.fileExists(atPath: usageFile.path)) diff --git a/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift b/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift index c120e04079..e5d514f394 100644 --- a/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift +++ b/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift @@ -278,6 +278,41 @@ struct UsageStoreCachedTokenHydrationTests { #expect(store.tokenLastAttemptAt(for: .codex) == nil) } + @Test + func `cache clear wins over in flight cached codex hydration`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let settings = Self.makeCodexOnlySettings(historyDays: 1) + let options = CostUsageScanner.Options(cacheRoot: env.cacheRoot) + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + costUsageFetcher: CostUsageFetcher(scannerOptions: options), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + let gate = CachedTokenHydrationGate() + store._test_cachedCodexTokenSnapshotLoaderOverride = { _, _, _ in + await gate.enter() + return (Self.cachedTokenSnapshot(), Date(), nil) + } + + let hydration = store.hydrateCachedTokenSnapshots() + await gate.waitForStart() + let artifactDirectory = env.cacheRoot.appendingPathComponent("cost-usage", isDirectory: true) + try FileManager.default.createDirectory(at: artifactDirectory, withIntermediateDirectories: true) + let clearResult = CostUsageCacheLocations.clearAllCostUsageCaches( + in: [artifactDirectory], + stateRoot: env.cacheRoot, + fileManager: .default) + await gate.release() + await hydration?.value + + #expect(clearResult.errorDescription == nil) + #expect(store.tokenSnapshot(for: .codex) == nil) + #expect(store.tokenLastAttemptAt(for: .codex) == nil) + } + private static func makeCodexOnlySettings(historyDays: Int) -> SettingsStore { let suite = "UsageStoreCachedTokenHydrationTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! From 1001c152e0afec4c7bc9c60c15b5dd1d53eeb5e6 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 3 Aug 2026 06:41:17 -0700 Subject: [PATCH 094/183] Protect proxy replacement boundaries --- Sources/CodexBar/UsageStore+TokenCost.swift | 17 +++++++----- .../CLIProxyAPIUsageTelemetry.swift | 23 ++++++++++------ .../CLIProxyAPIUsageCacheTests.swift | 26 +++++++++++++------ .../CLIProxyAPIUsageStoreTests.swift | 23 ++++++++++++++-- 4 files changed, 64 insertions(+), 25 deletions(-) diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index ad736162a4..37f9ca2356 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -474,7 +474,7 @@ extension UsageStore { } func clearCostUsageCache( - clearDirectories: (@Sendable () async -> String?)? = nil, + clearDirectories: (@Sendable () async -> (cleared: Int, errorMessage: String?))? = nil, fileManager: FileManager = .default) async -> String? { guard !self.costUsageCacheClearInProgress else { return nil } @@ -495,17 +495,20 @@ extension UsageStore { let cacheDirectories = CostUsageCacheLocations.directories(fileManager: fileManager) let cliProxyAPIStateRoot = cacheDirectories[1].deletingLastPathComponent() - let errorMessage: String? = if let clearDirectories { - await clearDirectories() + let clearResult: (didClear: Bool, errorMessage: String?) + if let clearDirectories { + let result = await clearDirectories() + clearResult = (result.errorMessage == nil || result.cleared > 0, result.errorMessage) } else { - await Task.detached(priority: .utility) { + let result = await Task.detached(priority: .utility) { CostUsageCacheLocations.clearAllCostUsageCaches( in: cacheDirectories, - stateRoot: cliProxyAPIStateRoot).errorDescription + stateRoot: cliProxyAPIStateRoot) }.value + clearResult = (result.errorDescription == nil || result.cleared > 0, result.errorDescription) } - guard errorMessage == nil else { return errorMessage } + guard clearResult.didClear else { return clearResult.errorMessage } self.clearTokenSnapshots() self.spendDashboardCodexCostCatchUpRevision &+= 1 @@ -514,7 +517,7 @@ extension UsageStore { self.lastTokenFetchScope.removeAll() self.tokenFailureGates[.codex]?.reset() self.tokenFailureGates[.claude]?.reset() - return nil + return clearResult.errorMessage } nonisolated static func tokenCostNoDataMessage(for provider: UsageProvider) -> String { diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 83058ef8f1..312e7d435a 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -741,6 +741,13 @@ public enum CLIProxyAPIConnectionSettingsStore { generationUpdate, fileManager: fileManager) } + let requiresIsolation = artifactDisposition == .purge + if requiresIsolation, + !operations.setDisconnectedState(true) + { + _ = operations.setDisconnectedState(wasDisconnected) + return false + } let artifactsUpdate: CostUsageCacheLocations.CLIProxyAPIArtifactsUpdate? switch artifactDisposition { case .preserve: @@ -751,7 +758,10 @@ public enum CLIProxyAPIConnectionSettingsStore { stateRoot: stateRoot, expectedGeneration: generationUpdate.generation, fileManager: fileManager) - else { return false } + else { + _ = operations.setDisconnectedState(wasDisconnected) + return false + } artifactsUpdate = update } @@ -765,9 +775,10 @@ public enum CLIProxyAPIConnectionSettingsStore { } } - // Publish the new generation before mutating credentials. If the process exits after - // the credential write, recovery will see the committed generation and finalize the - // staged purge instead of restoring artifacts from the previous configuration. + guard operations.store(settings) else { + rollback() + return false + } guard CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate( generationUpdate, fileManager: fileManager) @@ -775,10 +786,6 @@ public enum CLIProxyAPIConnectionSettingsStore { rollback() return false } - guard operations.store(settings) else { - rollback() - return false - } guard operations.setDisconnectedState(false) else { rollback() return false diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index d767f40aca..093f749c09 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -265,6 +265,8 @@ struct CLIProxyAPIUsageCacheTests { defer { try? fileManager.removeItem(at: root) } let existing = CLIProxyAPIConnectionSettings(managementKey: "old-management-key") let replacement = CLIProxyAPIConnectionSettings(managementKey: "new-management-key") + let disconnected = LockIsolated(false) + let wasIsolatedAtStore = LockIsolated(false) let saved = CLIProxyAPIConnectionSettingsStore.saveSerialized( replacement, @@ -272,13 +274,21 @@ struct CLIProxyAPIUsageCacheTests { stateRoot: root, fileManager: fileManager, operations: .init( - isDisconnected: { false }, + isDisconnected: { disconnected.value }, loadStored: { .found(existing) }, - store: { _ in false }, - setDisconnectedState: { _ in true }, + store: { _ in + wasIsolatedAtStore.setValue(disconnected.value) + return false + }, + setDisconnectedState: { + disconnected.setValue($0) + return true + }, restore: { _ in true })) #expect(!saved) + #expect(wasIsolatedAtStore.value) + #expect(!disconnected.value) #expect(fileManager.fileExists(atPath: usageFile.path)) } @@ -352,7 +362,7 @@ struct CLIProxyAPIUsageCacheTests { } @Test - func `failed save still invalidates in flight work before credential mutation`() { + func `failed save does not publish its staged configuration generation`() { let fileManager = FileManager.default let root = fileManager.temporaryDirectory .appendingPathComponent("cliproxy-generation-failure-\(UUID().uuidString)", isDirectory: true) @@ -370,11 +380,11 @@ struct CLIProxyAPIUsageCacheTests { restore: { _ in true }))) #expect(CostUsageCacheLocations.cliProxyAPIConfigurationGeneration( stateRoot: root, - fileManager: fileManager) != nil) + fileManager: fileManager) == nil) } @Test - func `generation publication failure prevents replacement credential mutation and restores telemetry`() throws { + func `generation publication failure restores replacement credentials state and telemetry`() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory .appendingPathComponent("cliproxy-generation-commit-failure-\(UUID().uuidString)", isDirectory: true) @@ -391,7 +401,6 @@ struct CLIProxyAPIUsageCacheTests { let storedSettings = LockIsolated(existing) let disconnected = LockIsolated(true) let didStore = LockIsolated(false) - try fileManager.createDirectory(at: generationURL, withIntermediateDirectories: true) let saved = CLIProxyAPIConnectionSettingsStore.saveSerialized( replacement, @@ -404,6 +413,7 @@ struct CLIProxyAPIUsageCacheTests { store: { settings in didStore.setValue(true) storedSettings.setValue(settings) + try? FileManager.default.createDirectory(at: generationURL, withIntermediateDirectories: true) return true }, setDisconnectedState: { value in @@ -417,7 +427,7 @@ struct CLIProxyAPIUsageCacheTests { })) #expect(!saved) - #expect(!didStore.value) + #expect(didStore.value) #expect(storedSettings.value == existing) #expect(disconnected.value) #expect(fileManager.fileExists(atPath: usageFile.path)) diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift index d32a9686f3..c42a38fe10 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift @@ -404,7 +404,7 @@ struct CLIProxyAPIUsageStoreTests { let error = await store.clearCostUsageCache(clearDirectories: { deletionStartedAfterDrain.setValue(collectorFinished.value) - return nil + return (cleared: 0, errorMessage: nil) }) store.stopCLIProxyAPIUsageCollector() @@ -451,7 +451,7 @@ struct CLIProxyAPIUsageStoreTests { let error = await store.clearCostUsageCache(clearDirectories: { deletionStartedAfterDrain.setValue(refreshFinished.value) - return nil + return (cleared: 0, errorMessage: nil) }) await refreshTask.value @@ -491,6 +491,25 @@ struct CLIProxyAPIUsageStoreTests { #expect(!FileManager.default.fileExists(atPath: cacheDirectory.path)) } + @Test + func `partial cost cache clear invalidates in memory snapshots`() async { + let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + store.publishTokenSnapshot(Self.tokenSnapshot(), for: .codex) + + let error = await store.clearCostUsageCache(clearDirectories: { + (cleared: 1, errorMessage: "Could not remove every cache directory") + }) + + #expect(error != nil) + #expect(store.tokenSnapshot(for: .codex) == nil) + } + private static func tokenSnapshot() -> CostUsageTokenSnapshot { CostUsageTokenSnapshot( sessionTokens: 10, From fc4aab8673541d749f02dabfc17a847b05aa9aa9 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 3 Aug 2026 16:46:40 -0700 Subject: [PATCH 095/183] Serialize proxy scan attribution state --- .../CLIProxyAPIAttributionResolver.swift | 3 +- .../CLIProxyAPIUsageTelemetry.swift | 21 +++++++ .../CostUsage/CostUsageScanner+Claude.swift | 56 +++++++++++++------ 3 files changed, 63 insertions(+), 17 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift index 2261de06cf..23fdc9e526 100644 --- a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift +++ b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift @@ -97,6 +97,7 @@ struct CLIProxyAPIAttributionResolver: Sendable { cacheRoot: URL? = nil, fileManager: FileManager = .default, forceReload: Bool = false, + usageRecords: [CLIProxyAPIUsageRecord]? = nil, checkCancellation: (() throws -> Void)? = nil) throws -> Self { let observations = try self.loadObservations( @@ -106,7 +107,7 @@ struct CLIProxyAPIAttributionResolver: Sendable { checkCancellation: checkCancellation) return Self( observations: observations, - usageRecords: CLIProxyAPIUsageCacheIO.load(cacheRoot: cacheRoot), + usageRecords: usageRecords ?? CLIProxyAPIUsageCacheIO.load(cacheRoot: cacheRoot), authProviders: self.loadAuthProviders(home: home, fileManager: fileManager), codexOAuthModelAliases: self.loadCodexOAuthModelAliases(home: home, fileManager: fileManager), hasConfiguredOpenAIAPIUpstream: self.hasConfiguredOpenAIAPIUpstream( diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 312e7d435a..49553b374f 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -190,6 +190,27 @@ enum CLIProxyAPIUsageCacheIO { now: now) } + /// Reads and prunes the cache while the caller already owns the CLIProxyAPI interprocess lock. + static func loadAssumingInterprocessLockHeld( + cacheRoot: URL?, + now: Date = Date()) -> [CLIProxyAPIUsageRecord] + { + let legacyCacheRoot = cacheRoot == nil ? self.defaultLegacyCacheRoot() : nil + let cutoff = now.addingTimeInterval(-self.maximumRecordAge) + return self.withExclusiveAccess { + guard let currentCache = self.loadCache( + cacheRoot: cacheRoot, + legacyCacheRoot: legacyCacheRoot) + else { return [] } + let retainedRecords = currentCache.records.filter { $0.timestamp >= cutoff } + let retainedCache = Cache(records: retainedRecords) + if retainedCache != currentCache { + _ = self.save(retainedCache, cacheRoot: cacheRoot) + } + return retainedRecords + } + } + static func load( cacheRoot: URL?, legacyCacheRoot: URL?, diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 9f95db453a..2c14416dd0 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -885,6 +885,41 @@ extension CostUsageScanner { // Root mtime caching removed — see comment above. } + private struct ClaudeCLIProxyAPIAttributionState { + let configurationGeneration: String? + let resolver: CLIProxyAPIAttributionResolver? + } + + private static func captureClaudeCLIProxyAPIAttributionState( + options: Options, + checkCancellation: CancellationCheck?) throws -> ClaudeCLIProxyAPIAttributionState + { + try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( + stateRoot: options.cacheRoot) + { + let configurationGeneration = CostUsageCacheLocations.cliProxyAPIConfigurationGeneration( + stateRoot: options.cacheRoot) + let attributionResolver: CLIProxyAPIAttributionResolver? + if !CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected(stateRoot: options.cacheRoot), + let home = options.cliProxyAPIHome + { + let usageRecords = CLIProxyAPIUsageCacheIO.loadAssumingInterprocessLockHeld( + cacheRoot: options.cacheRoot) + attributionResolver = try CLIProxyAPIAttributionResolver.load( + home: home, + cacheRoot: options.cacheRoot, + forceReload: options.forceRescan, + usageRecords: usageRecords, + checkCancellation: checkCancellation) + } else { + attributionResolver = nil + } + return ClaudeCLIProxyAPIAttributionState( + configurationGeneration: configurationGeneration, + resolver: attributionResolver) + } + } + static func loadClaudeDaily( provider: UsageProvider, range: CostUsageDayRange, @@ -892,9 +927,11 @@ extension CostUsageScanner { options: Options, checkCancellation: CancellationCheck?) throws -> CostUsageDailyReport { - try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock(stateRoot: options.cacheRoot) {} - let cliProxyAPIConfigurationGeneration = CostUsageCacheLocations - .cliProxyAPIConfigurationGeneration(stateRoot: options.cacheRoot) + let cliProxyAPIAttributionState = try self.captureClaudeCLIProxyAPIAttributionState( + options: options, + checkCancellation: checkCancellation) + let cliProxyAPIConfigurationGeneration = cliProxyAPIAttributionState.configurationGeneration + let attributionResolver = cliProxyAPIAttributionState.resolver var cache = CostUsageCacheIO.load( provider: provider, cacheRoot: options.cacheRoot, @@ -914,19 +951,6 @@ extension CostUsageScanner { || nowMs - cache.lastScanUnixMs > refreshMs let providerFilter = options.claudeLogProviderFilter - let cliProxyAPIAttributionEnabled = !CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( - stateRoot: options.cacheRoot) - let attributionResolver: CLIProxyAPIAttributionResolver? = if cliProxyAPIAttributionEnabled, - let home = options.cliProxyAPIHome - { - try CLIProxyAPIAttributionResolver.load( - home: home, - cacheRoot: options.cacheRoot, - forceReload: options.forceRescan, - checkCancellation: checkCancellation) - } else { - nil - } var touched: Set = [] From 845eb70389d518cacf915e636a874cf06372911e Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 3 Aug 2026 17:35:35 -0700 Subject: [PATCH 096/183] Detect proxy changes after hydration --- Sources/CodexBar/UsageStore+CLIProxyAPI.swift | 11 ++++-- Sources/CodexBar/UsageStore.swift | 4 +- .../CLIProxyAPIUsageStoreTests.swift | 37 +++++++++++++++++-- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift index c0fd325d0e..dfef6cc305 100644 --- a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift +++ b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift @@ -17,12 +17,15 @@ extension UsageStore { private static let cliProxyAPIUsageCollectionInterval: Duration = .seconds(30) private static let cliProxyAPIPendingPruneInterval: Duration = .seconds(24 * 60 * 60) - func startCLIProxyAPIUsageCollector() { + func startCLIProxyAPIUsageCollector(initialConfigurationGeneration: String? = nil) { self.stopCLIProxyAPIUsageCollector() let pendingPruneInterval = Self.cliProxyAPIPendingPruneInterval + let initialConfigurationGeneration = initialConfigurationGeneration ?? + CostUsageCacheLocations.cliProxyAPIConfigurationGeneration() self.cliProxyAPIUsageCollectorTask = Task.detached(priority: .utility) { [weak self] in var nextPendingPruneAt: ContinuousClock.Instant? - var collectorState = CLIProxyAPIUsageCollectorState() + var collectorState = CLIProxyAPIUsageCollectorState( + configurationGeneration: initialConfigurationGeneration) while !Task.isCancelled { let now = ContinuousClock.now if nextPendingPruneAt.map({ now >= $0 }) ?? true { @@ -107,7 +110,7 @@ extension UsageStore { case .collected: let currentGeneration = configurationGeneration() if collectorState.configurationAvailability == .unavailable || - (collectorState.configurationAvailability == .available && + (collectorState.configurationGeneration != nil && collectorState.configurationGeneration != currentGeneration) { await self.refreshCLIProxyAPICostAttribution(refresh: refresh) @@ -116,7 +119,7 @@ extension UsageStore { collectorState.configurationGeneration = currentGeneration case .failed: let currentGeneration = configurationGeneration() - if collectorState.configurationAvailability == .available, + if collectorState.configurationGeneration != nil, collectorState.configurationGeneration != currentGeneration { self.invalidateCLIProxyAPICostAttribution(widgetReason: "cliproxyapi-configuration-changed") diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 2d527c7955..8ffc50059d 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -519,6 +519,7 @@ final class UsageStore { effectivePATH: PathBuilder.effectivePATH(purposes: [.rpc, .tty, .nodeTooling]), loginShellPATH: LoginShellPathCache.shared.current?.joined(separator: ":")) guard self.startupBehavior.automaticallyStartsBackgroundWork else { return } + let cliProxyAPIConfigurationGeneration = self.costUsageFetcher.cliProxyAPIConfigurationGeneration() self.hydrateCachedTokenSnapshots() self.detectVersions() self.updateProviderRuntimes() @@ -536,7 +537,8 @@ final class UsageStore { Task { await self.refresh(enrichmentMode: .automatic) } self.startTimer() self.startTokenTimer() - self.startCLIProxyAPIUsageCollector() + self.startCLIProxyAPIUsageCollector( + initialConfigurationGeneration: cliProxyAPIConfigurationGeneration) } var iconStyle: IconStyle { diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift index c42a38fe10..216f718d4e 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift @@ -339,7 +339,39 @@ struct CLIProxyAPIUsageStoreTests { } @Test - func `failed collection invalidates stale snapshots after configuration changes`() async { + func `first collection detects a generation change after startup hydration`() async { + let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") + settings.costUsageEnabled = true + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + let snapshot = Self.tokenSnapshot() + store.publishTokenSnapshot(snapshot, for: .codex) + store.publishTokenSnapshot(snapshot, for: .claude) + var refreshes: [(UsageProvider, Bool)] = [] + + let collectorState = await store.handleCLIProxyAPIUsageCollectionResult( + .collected(0), + collectorState: CLIProxyAPIUsageCollectorState( + configurationGeneration: "hydrated-generation"), + configurationGeneration: { "replacement-generation" }, + refresh: { provider, force in + refreshes.append((provider, force)) + }) + + #expect(collectorState.configurationAvailability == .available) + #expect(collectorState.configurationGeneration == "replacement-generation") + #expect(store.tokenSnapshot(for: .codex) == nil) + #expect(store.tokenSnapshot(for: .claude) == nil) + #expect(refreshes.map(\.0) == [.claude, .codex]) + #expect(refreshes.map(\.1) == [true, true]) + } + + @Test + func `first failed collection invalidates stale snapshots after configuration changes`() async { let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") settings.costUsageEnabled = true let store = UsageStore( @@ -355,11 +387,10 @@ struct CLIProxyAPIUsageStoreTests { var collectorState = await store.handleCLIProxyAPIUsageCollectionResult( .failed("replacement endpoint unavailable"), collectorState: CLIProxyAPIUsageCollectorState( - configurationAvailability: .available, configurationGeneration: "before-replacement"), configurationGeneration: { "after-replacement" }) - #expect(collectorState.configurationAvailability == .available) + #expect(collectorState.configurationAvailability == .unknown) #expect(collectorState.configurationGeneration == "after-replacement") #expect(store.tokenSnapshot(for: .codex) == nil) #expect(store.tokenSnapshot(for: .claude) == nil) From fb5b4a5b8351798284f68db613d31202ef0c3350 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 3 Aug 2026 18:00:19 -0700 Subject: [PATCH 097/183] Retry proxy telemetry cleanup --- Sources/CodexBar/UsageStore+CLIProxyAPI.swift | 36 ++++++++++++++- Sources/CodexBar/UsageStore.swift | 2 + .../CLIProxyAPIUsageStoreTests.swift | 45 +++++++++++++++++-- 3 files changed, 79 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift index dfef6cc305..678bf0bc01 100644 --- a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift +++ b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift @@ -19,6 +19,8 @@ extension UsageStore { func startCLIProxyAPIUsageCollector(initialConfigurationGeneration: String? = nil) { self.stopCLIProxyAPIUsageCollector() + self.cliProxyAPICleanupRetryTask?.cancel() + self.cliProxyAPICleanupRetryTask = nil let pendingPruneInterval = Self.cliProxyAPIPendingPruneInterval let initialConfigurationGeneration = initialConfigurationGeneration ?? CostUsageCacheLocations.cliProxyAPIConfigurationGeneration() @@ -55,10 +57,13 @@ extension UsageStore { @discardableResult func removeCLIProxyAPIConfiguration( - remove: (() async -> CLIProxyAPIConfigurationRemovalResult)? = nil) async + remove: (() async -> CLIProxyAPIConfigurationRemovalResult)? = nil, + scheduleCleanupRetry: (() -> Void)? = nil) async -> CLIProxyAPIConfigurationRemovalResult { let collectorTask = self.stopCLIProxyAPIUsageCollector() + self.cliProxyAPICleanupRetryTask?.cancel() + self.cliProxyAPICleanupRetryTask = nil await collectorTask?.value let result = if let remove { await remove() @@ -70,9 +75,38 @@ extension UsageStore { if result != .configurationRemovalFailed { self.invalidateCLIProxyAPICostAttribution() } + if result == .telemetryCleanupFailed { + if let scheduleCleanupRetry { + scheduleCleanupRetry() + } else { + self.startCLIProxyAPICleanupRetry() + } + } return result } + @discardableResult + func startCLIProxyAPICleanupRetry( + retryInterval: Duration = .seconds(30), + maintenance: @escaping @Sendable () -> Bool = { + CLIProxyAPIUsageCollector.pruneExpiredUsage() + }) -> Task + { + self.cliProxyAPICleanupRetryTask?.cancel() + let task = Task.detached(priority: .utility) { + while !Task.isCancelled { + if maintenance() { return } + do { + try await Task.sleep(for: retryInterval) + } catch { + return + } + } + } + self.cliProxyAPICleanupRetryTask = task + return task + } + func collectCLIProxyAPIUsageNow( collector: (@Sendable () async -> CLIProxyAPIUsageCollectionResult)? = nil) async -> CLIProxyAPIUsageCollectionResult diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 8ffc50059d..b3cf02458c 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -411,6 +411,7 @@ final class UsageStore { /// Background load task; cleared on deinit and on the cancel test seam. @ObservationIgnored var planUtilizationHistoryLoadTask: Task? @ObservationIgnored var cliProxyAPIUsageCollectorTask: Task? + @ObservationIgnored var cliProxyAPICleanupRetryTask: Task? /// Set once after the load completes. Gates mutation paths and sync menu /// accessors so they cannot race the decode or write empty history back to disk. @ObservationIgnored var planUtilizationHistoryLoaded: Bool = false @@ -929,6 +930,7 @@ final class UsageStore { self.resetBoundaryRefreshTask?.cancel() self.planUtilizationHistoryLoadTask?.cancel() self.cliProxyAPIUsageCollectorTask?.cancel() + self.cliProxyAPICleanupRetryTask?.cancel() } enum SessionQuotaWindowSource: String { diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift index 216f718d4e..c0f1a9f244 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift @@ -20,6 +20,24 @@ private actor CLIProxyAPIUsageCollectorCancellationRecorder { } } +private final class CLIProxyAPICleanupRetryRecorder: @unchecked Sendable { + private let lock = NSLock() + private var attempts = 0 + + func attempt() -> Bool { + self.lock.lock() + defer { self.lock.unlock() } + self.attempts += 1 + return self.attempts >= 2 + } + + var count: Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.attempts + } +} + @MainActor struct CLIProxyAPIUsageStoreTests { @Test @@ -158,13 +176,34 @@ struct CLIProxyAPIUsageStoreTests { startupBehavior: .testing, environmentBase: environment) store.publishTokenSnapshot(Self.tokenSnapshot(), for: .codex) + var scheduledCleanupRetry = false - let removed = await store.removeCLIProxyAPIConfiguration { - .telemetryCleanupFailed - } + let removed = await store.removeCLIProxyAPIConfiguration( + remove: { .telemetryCleanupFailed }, + scheduleCleanupRetry: { scheduledCleanupRetry = true }) #expect(removed == .telemetryCleanupFailed) #expect(store.tokenSnapshot(for: .codex) == nil) + #expect(scheduledCleanupRetry) + } + + @Test + func `telemetry cleanup maintenance retries until transaction recovery succeeds`() async { + let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + let recorder = CLIProxyAPICleanupRetryRecorder() + + let task = store.startCLIProxyAPICleanupRetry(retryInterval: .milliseconds(1)) { + recorder.attempt() + } + await task.value + + #expect(recorder.count == 2) } @Test From 6132516105708beb8655e2fbb2b39b09cc575b39 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 3 Aug 2026 18:25:35 -0700 Subject: [PATCH 098/183] Refresh proxy configuration state --- .../PreferencesSpendDashboardPane.swift | 24 ++++++++-- .../SpendDashboardProxyAttributionTests.swift | 46 +++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index 04837bea40..995728e237 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -103,6 +103,21 @@ func spendDashboardModelHistoryPresentation( return group.modelHistoryCompleteness == .incomplete ? .partial : .complete } +func spendDashboardCLIProxyAPIConfigurationPresentation( + loadResult: KeychainCacheStore.LoadResult, + currentBaseURL: String, + hasSavedConfiguration: Bool) -> (baseURL: String, hasSavedConfiguration: Bool) +{ + switch loadResult { + case let .found(configuration): + (configuration.baseURL, true) + case .missing, .invalid: + (currentBaseURL, false) + case .temporarilyUnavailable: + (currentBaseURL, hasSavedConfiguration) + } +} + @MainActor struct SpendDashboardPane: View { @Bindable var settings: SettingsStore @@ -464,9 +479,12 @@ struct SpendDashboardPane: View { } private func loadCLIProxyAPIConfiguration() { - guard let configuration = CLIProxyAPIConnectionSettingsStore.load() else { return } - self.cliProxyAPIBaseURL = configuration.baseURL - self.cliProxyAPIHasSavedConfiguration = true + let presentation = spendDashboardCLIProxyAPIConfigurationPresentation( + loadResult: CLIProxyAPIConnectionSettingsStore.loadResult(), + currentBaseURL: self.cliProxyAPIBaseURL, + hasSavedConfiguration: self.cliProxyAPIHasSavedConfiguration) + self.cliProxyAPIBaseURL = presentation.baseURL + self.cliProxyAPIHasSavedConfiguration = presentation.hasSavedConfiguration } private func saveAndTestCLIProxyAPIConfiguration() async { diff --git a/Tests/CodexBarTests/SpendDashboardProxyAttributionTests.swift b/Tests/CodexBarTests/SpendDashboardProxyAttributionTests.swift index 19f4b99e7c..3292f3d077 100644 --- a/Tests/CodexBarTests/SpendDashboardProxyAttributionTests.swift +++ b/Tests/CodexBarTests/SpendDashboardProxyAttributionTests.swift @@ -4,6 +4,52 @@ import Testing @testable import CodexBar struct SpendDashboardProxyAttributionTests { + @Test + func `missing proxy configuration clears the saved presentation`() { + let presentation = spendDashboardCLIProxyAPIConfigurationPresentation( + loadResult: .missing, + currentBaseURL: "http://localhost:8317", + hasSavedConfiguration: true) + + #expect(presentation.baseURL == "http://localhost:8317") + #expect(!presentation.hasSavedConfiguration) + } + + @Test + func `invalid proxy configuration clears the saved presentation`() { + let presentation = spendDashboardCLIProxyAPIConfigurationPresentation( + loadResult: .invalid, + currentBaseURL: "http://localhost:8317", + hasSavedConfiguration: true) + + #expect(presentation.baseURL == "http://localhost:8317") + #expect(!presentation.hasSavedConfiguration) + } + + @Test + func `temporarily unavailable proxy configuration preserves the presentation`() { + let presentation = spendDashboardCLIProxyAPIConfigurationPresentation( + loadResult: .temporarilyUnavailable, + currentBaseURL: "http://localhost:8317", + hasSavedConfiguration: true) + + #expect(presentation.baseURL == "http://localhost:8317") + #expect(presentation.hasSavedConfiguration) + } + + @Test + func `found proxy configuration refreshes the presentation`() { + let presentation = spendDashboardCLIProxyAPIConfigurationPresentation( + loadResult: .found(CLIProxyAPIConnectionSettings( + baseURL: "http://127.0.0.1:8317", + managementKey: "test-key")), + currentBaseURL: CLIProxyAPIConnectionSettings.defaultBaseURL, + hasSavedConfiguration: false) + + #expect(presentation.baseURL == "http://127.0.0.1:8317") + #expect(presentation.hasSavedConfiguration) + } + @Test func `unresolved route describes known facts without an unknown warning`() { let attribution = CostUsageAttribution( From a4fb6e17628c64e133f56641e9b5c8d425e8a21b Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 3 Aug 2026 18:53:54 -0700 Subject: [PATCH 099/183] Recover proxy save isolation --- .../CLIProxyAPIUsageTelemetry.swift | 12 +-- .../CostUsageCacheLocations.swift | 30 ++++++-- .../CLIProxyAPIUsageCacheTests.swift | 75 +++++++++++++++++++ 3 files changed, 104 insertions(+), 13 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 49553b374f..894f17998b 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -762,13 +762,6 @@ public enum CLIProxyAPIConnectionSettingsStore { generationUpdate, fileManager: fileManager) } - let requiresIsolation = artifactDisposition == .purge - if requiresIsolation, - !operations.setDisconnectedState(true) - { - _ = operations.setDisconnectedState(wasDisconnected) - return false - } let artifactsUpdate: CostUsageCacheLocations.CLIProxyAPIArtifactsUpdate? switch artifactDisposition { case .preserve: @@ -778,7 +771,10 @@ public enum CLIProxyAPIConnectionSettingsStore { in: artifactDirectories, stateRoot: stateRoot, expectedGeneration: generationUpdate.generation, - fileManager: fileManager) + fileManager: fileManager, + disconnectedStateAfterCommit: false, + disconnectedStateAfterRollback: wasDisconnected, + prepareState: { operations.setDisconnectedState(true) }) else { _ = operations.setDisconnectedState(wasDisconnected) return false diff --git a/Sources/CodexBarCore/CostUsageCacheLocations.swift b/Sources/CodexBarCore/CostUsageCacheLocations.swift index 35097fb88e..8bf977ac7e 100644 --- a/Sources/CodexBarCore/CostUsageCacheLocations.swift +++ b/Sources/CodexBarCore/CostUsageCacheLocations.swift @@ -42,6 +42,8 @@ public enum CostUsageCacheLocations { let expectedGeneration: String let moves: [Move] + let disconnectedStateAfterCommit: Bool? + let disconnectedStateAfterRollback: Bool? } static let cliProxyAPIUsageFileName = "cliproxyapi-usage-v1.json" @@ -186,7 +188,10 @@ public enum CostUsageCacheLocations { in directories: [URL], stateRoot: URL?, expectedGeneration: String, - fileManager: FileManager) -> CLIProxyAPIArtifactsUpdate? + fileManager: FileManager, + disconnectedStateAfterCommit: Bool? = nil, + disconnectedStateAfterRollback: Bool? = nil, + prepareState: () -> Bool = { true }) -> CLIProxyAPIArtifactsUpdate? { let identifier = UUID().uuidString let moves = self.cliProxyAPIArtifactURLs(in: directories) @@ -200,8 +205,6 @@ public enum CostUsageCacheLocations { ".\(originalURL.lastPathComponent).\(identifier).replacement-backup", isDirectory: false)) } - guard !moves.isEmpty else { return CLIProxyAPIArtifactsUpdate(moves: []) } - let manifestURL = self.cliProxyAPIArtifactsTransactionURL( stateRoot: stateRoot, fileManager: fileManager) @@ -209,7 +212,9 @@ public enum CostUsageCacheLocations { expectedGeneration: expectedGeneration, moves: moves.map { .init(originalPath: $0.originalURL.path, stagedPath: $0.stagedURL.path) - }) + }, + disconnectedStateAfterCommit: disconnectedStateAfterCommit, + disconnectedStateAfterRollback: disconnectedStateAfterRollback) do { try fileManager.createDirectory( at: manifestURL.deletingLastPathComponent(), @@ -220,6 +225,10 @@ public enum CostUsageCacheLocations { } let update = CLIProxyAPIArtifactsUpdate(moves: moves, manifestURL: manifestURL) + guard prepareState() else { + _ = self.removeCLIProxyAPIArtifactsManifest(manifestURL, fileManager: fileManager) + return nil + } for move in moves { do { try fileManager.moveItem(at: move.originalURL, to: move.stagedURL) @@ -329,9 +338,20 @@ public enum CostUsageCacheLocations { stagedURL: URL(fileURLWithPath: $0.stagedPath)) }, manifestURL: manifestURL) - if self.cliProxyAPIConfigurationGeneration(stateRoot: stateRoot, fileManager: fileManager) == + let didCommit = self.cliProxyAPIConfigurationGeneration(stateRoot: stateRoot, fileManager: fileManager) == manifest.expectedGeneration + let disconnectedState = didCommit + ? manifest.disconnectedStateAfterCommit + : manifest.disconnectedStateAfterRollback + if let disconnectedState, + !self.setCLIProxyAPIExplicitlyDisconnected( + disconnectedState, + stateRoot: stateRoot, + fileManager: fileManager) { + return false + } + if didCommit { return self.discardCLIProxyAPIArtifactsUpdate(update, fileManager: fileManager) } return self.restoreCLIProxyAPIArtifactsUpdate(update, fileManager: fileManager) diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index 093f749c09..b7710f9ef6 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -875,6 +875,81 @@ struct CLIProxyAPIUsageCacheTests { } struct CLIProxyAPITransactionRecoveryTests { + @Test + func `interrupted committed save clears its isolation marker on the next lock`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-save-marker-finalize-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: root) } + + let generationUpdate = try #require(CostUsageCacheLocations + .prepareCLIProxyAPIConfigurationGenerationUpdate(stateRoot: root, fileManager: fileManager)) + let artifactsUpdate = try #require(CostUsageCacheLocations.prepareCLIProxyAPIArtifactsUpdate( + in: [], + stateRoot: root, + expectedGeneration: generationUpdate.generation, + fileManager: fileManager, + disconnectedStateAfterCommit: false, + disconnectedStateAfterRollback: false, + prepareState: { + CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected( + true, + stateRoot: root, + fileManager: fileManager) + })) + #expect(CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( + stateRoot: root, + fileManager: fileManager)) + #expect(artifactsUpdate.manifestURL.map { fileManager.fileExists(atPath: $0.path) } == true) + #expect(CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate( + generationUpdate, + fileManager: fileManager)) + + try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock(stateRoot: root, fileManager: fileManager) {} + + #expect(!CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( + stateRoot: root, + fileManager: fileManager)) + #expect(artifactsUpdate.manifestURL.map { !fileManager.fileExists(atPath: $0.path) } == true) + } + + @Test + func `interrupted uncommitted save restores its previous isolation marker on the next lock`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-save-marker-rollback-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: root) } + + let generationUpdate = try #require(CostUsageCacheLocations + .prepareCLIProxyAPIConfigurationGenerationUpdate(stateRoot: root, fileManager: fileManager)) + let artifactsUpdate = try #require(CostUsageCacheLocations.prepareCLIProxyAPIArtifactsUpdate( + in: [], + stateRoot: root, + expectedGeneration: generationUpdate.generation, + fileManager: fileManager, + disconnectedStateAfterCommit: false, + disconnectedStateAfterRollback: false, + prepareState: { + CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected( + true, + stateRoot: root, + fileManager: fileManager) + })) + #expect(CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( + stateRoot: root, + fileManager: fileManager)) + + try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock(stateRoot: root, fileManager: fileManager) {} + + #expect(!CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( + stateRoot: root, + fileManager: fileManager)) + #expect(artifactsUpdate.manifestURL.map { !fileManager.fileExists(atPath: $0.path) } == true) + CostUsageCacheLocations.discardCLIProxyAPIConfigurationGenerationUpdate( + generationUpdate, + fileManager: fileManager) + } + @Test func `interrupted uncommitted replacement restores staged artifacts on the next lock`() throws { let fileManager = FileManager.default From 8bcafdf47e52d8eda453d917b859678c4fe5cab4 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 3 Aug 2026 19:19:31 -0700 Subject: [PATCH 100/183] Invalidate proxy telemetry before credentials --- .../CodexBarCore/CLIProxyAPIUsageTelemetry.swift | 11 +++++++---- .../CLIProxyAPIUsageCacheTests.swift | 16 +++++++++++----- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 894f17998b..724edafc90 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -792,10 +792,6 @@ public enum CLIProxyAPIConnectionSettingsStore { } } - guard operations.store(settings) else { - rollback() - return false - } guard CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate( generationUpdate, fileManager: fileManager) @@ -803,6 +799,13 @@ public enum CLIProxyAPIConnectionSettingsStore { rollback() return false } + // Publish the telemetry invalidation before replacing credentials. If the process exits + // during the Keychain write, recovery will discard the staged artifacts instead of exposing + // telemetry collected under the previous credentials with the replacement configuration. + guard operations.store(settings) else { + rollback() + return false + } guard operations.setDisconnectedState(false) else { rollback() return false diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index b7710f9ef6..c499ac26f7 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -203,7 +203,7 @@ struct CLIProxyAPIUsageCacheTests { } @Test - func `reconnect stages stranded telemetry before storing credentials`() throws { + func `reconnect publishes telemetry invalidation before storing credentials`() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory .appendingPathComponent("cliproxy-reconnect-stage-\(UUID().uuidString)", isDirectory: true) @@ -214,6 +214,7 @@ struct CLIProxyAPIUsageCacheTests { defer { try? fileManager.removeItem(at: root) } let artifactWasStagedAtStore = LockIsolated(false) + let generationWasPublishedAtStore = LockIsolated(false) let saved = CLIProxyAPIConnectionSettingsStore.saveSerialized( CLIProxyAPIConnectionSettings(managementKey: "test-management-key"), artifactDirectories: [costUsage], @@ -224,6 +225,10 @@ struct CLIProxyAPIUsageCacheTests { loadStored: { .missing }, store: { _ in artifactWasStagedAtStore.setValue(!FileManager.default.fileExists(atPath: usageFile.path)) + generationWasPublishedAtStore.setValue( + CostUsageCacheLocations.cliProxyAPIConfigurationGeneration( + stateRoot: root, + fileManager: .default) != nil) return true }, setDisconnectedState: { _ in true }, @@ -231,6 +236,7 @@ struct CLIProxyAPIUsageCacheTests { #expect(saved) #expect(artifactWasStagedAtStore.value) + #expect(generationWasPublishedAtStore.value) #expect(!fileManager.fileExists(atPath: usageFile.path)) } @@ -362,7 +368,7 @@ struct CLIProxyAPIUsageCacheTests { } @Test - func `failed save does not publish its staged configuration generation`() { + func `failed credential save keeps its published telemetry invalidation`() { let fileManager = FileManager.default let root = fileManager.temporaryDirectory .appendingPathComponent("cliproxy-generation-failure-\(UUID().uuidString)", isDirectory: true) @@ -380,7 +386,7 @@ struct CLIProxyAPIUsageCacheTests { restore: { _ in true }))) #expect(CostUsageCacheLocations.cliProxyAPIConfigurationGeneration( stateRoot: root, - fileManager: fileManager) == nil) + fileManager: fileManager) != nil) } @Test @@ -395,6 +401,7 @@ struct CLIProxyAPIUsageCacheTests { isDirectory: false) try fileManager.createDirectory(at: costUsage, withIntermediateDirectories: true) try Data("telemetry".utf8).write(to: usageFile) + try fileManager.createDirectory(at: generationURL, withIntermediateDirectories: true) defer { try? fileManager.removeItem(at: root) } let existing = CLIProxyAPIConnectionSettings(managementKey: "old-management-key") let replacement = CLIProxyAPIConnectionSettings(managementKey: "new-management-key") @@ -413,7 +420,6 @@ struct CLIProxyAPIUsageCacheTests { store: { settings in didStore.setValue(true) storedSettings.setValue(settings) - try? FileManager.default.createDirectory(at: generationURL, withIntermediateDirectories: true) return true }, setDisconnectedState: { value in @@ -427,7 +433,7 @@ struct CLIProxyAPIUsageCacheTests { })) #expect(!saved) - #expect(didStore.value) + #expect(!didStore.value) #expect(storedSettings.value == existing) #expect(disconnected.value) #expect(fileManager.fileExists(atPath: usageFile.path)) From c0fee4a35b4f0608a910d075916d55d9d63a2bb0 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 3 Aug 2026 19:42:24 -0700 Subject: [PATCH 101/183] Complete proxy transition recovery --- Sources/CodexBar/UsageStore+CLIProxyAPI.swift | 1 + .../CLIProxyAPIUsageTelemetry.swift | 10 +++-- .../CLIProxyAPIUsageCacheTests.swift | 9 +++-- .../CLIProxyAPIUsageStoreTests.swift | 37 ++++++++++++++++++- 4 files changed, 48 insertions(+), 9 deletions(-) diff --git a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift index 678bf0bc01..10c0cba321 100644 --- a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift +++ b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift @@ -157,6 +157,7 @@ extension UsageStore { collectorState.configurationGeneration != currentGeneration { self.invalidateCLIProxyAPICostAttribution(widgetReason: "cliproxyapi-configuration-changed") + collectorState.configurationAvailability = .unavailable collectorState.configurationGeneration = currentGeneration } case .disabled: diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 724edafc90..0bd60498f7 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -921,10 +921,6 @@ public enum CLIProxyAPIConnectionSettingsStore { fileManager: fileManager) } - guard operations.clearConfiguration() else { - rollback() - return .configurationRemovalFailed - } guard CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate( generationUpdate, fileManager: fileManager) @@ -932,6 +928,12 @@ public enum CLIProxyAPIConnectionSettingsStore { rollback() return .configurationRemovalFailed } + // Publish the telemetry invalidation before deleting credentials. Crash recovery can then only + // finalize the purge; it must never restore data after the integration has been removed. + guard operations.clearConfiguration() else { + rollback() + return .configurationRemovalFailed + } return CostUsageCacheLocations.discardCLIProxyAPIArtifactsUpdate( artifactsUpdate, fileManager: fileManager) ? .removed : .telemetryCleanupFailed diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index c499ac26f7..527b8f54e3 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -451,10 +451,12 @@ struct CLIProxyAPIUsageCacheTests { isDirectory: false) try fileManager.createDirectory(at: costUsage, withIntermediateDirectories: true) try Data("telemetry".utf8).write(to: usageFile) + try fileManager.createDirectory(at: generationURL, withIntermediateDirectories: true) defer { try? fileManager.removeItem(at: root) } let existing = CLIProxyAPIConnectionSettings(managementKey: "old-management-key") let storedSettings = LockIsolated(existing) let disconnected = LockIsolated(false) + let didClear = LockIsolated(false) let result = CLIProxyAPIConnectionSettingsStore.removeAndPurgeTelemetry( in: [costUsage], @@ -464,10 +466,8 @@ struct CLIProxyAPIUsageCacheTests { isDisconnected: { disconnected.value }, loadStored: { .found(existing) }, clearConfiguration: { - storedSettings.setValue(nil) - disconnected.setValue(true) - try? FileManager.default.createDirectory(at: generationURL, withIntermediateDirectories: true) - return true + didClear.setValue(true) + return false }, setDisconnectedState: { value in disconnected.setValue(value) @@ -480,6 +480,7 @@ struct CLIProxyAPIUsageCacheTests { })) #expect(result == .configurationRemovalFailed) + #expect(!didClear.value) #expect(storedSettings.value == existing) #expect(!disconnected.value) #expect(fileManager.fileExists(atPath: usageFile.path)) diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift index c0f1a9f244..a88ac3cc77 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift @@ -377,6 +377,41 @@ struct CLIProxyAPIUsageStoreTests { #expect(refreshes.map(\.1) == [true, true]) } + @Test + func `failed generation transition stays pending until collection succeeds`() async { + let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") + settings.costUsageEnabled = true + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + var refreshes: [(UsageProvider, Bool)] = [] + + var collectorState = await store.handleCLIProxyAPIUsageCollectionResult( + .failed("replacement unavailable"), + collectorState: CLIProxyAPIUsageCollectorState( + configurationAvailability: .available, + configurationGeneration: "old-generation"), + configurationGeneration: { "new-generation" }) + + #expect(collectorState.configurationAvailability == .unavailable) + #expect(collectorState.configurationGeneration == "new-generation") + + collectorState = await store.handleCLIProxyAPIUsageCollectionResult( + .collected(0), + collectorState: collectorState, + configurationGeneration: { "new-generation" }, + refresh: { provider, force in + refreshes.append((provider, force)) + }) + + #expect(collectorState.configurationAvailability == .available) + #expect(refreshes.map(\.0) == [.claude, .codex]) + #expect(refreshes.map(\.1) == [true, true]) + } + @Test func `first collection detects a generation change after startup hydration`() async { let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") @@ -429,7 +464,7 @@ struct CLIProxyAPIUsageStoreTests { configurationGeneration: "before-replacement"), configurationGeneration: { "after-replacement" }) - #expect(collectorState.configurationAvailability == .unknown) + #expect(collectorState.configurationAvailability == .unavailable) #expect(collectorState.configurationGeneration == "after-replacement") #expect(store.tokenSnapshot(for: .codex) == nil) #expect(store.tokenSnapshot(for: .claude) == nil) From 65cfa9aadf53c16cd35f18e3930ed18ab3519842 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 4 Aug 2026 04:01:37 -0700 Subject: [PATCH 102/183] Handle missing proxy credentials during removal --- .../CLIProxyAPIUsageTelemetry.swift | 24 ++++++++--- .../CLIProxyAPIUsageCacheTests.swift | 42 +++++++++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 0bd60498f7..64ba7a0252 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -940,17 +940,31 @@ public enum CLIProxyAPIConnectionSettingsStore { } private static func clearUnserialized() -> Bool { - let wasDisconnected = CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected() - guard wasDisconnected || CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected(true) else { + self.clearUnserialized( + isDisconnected: { CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected() }, + setDisconnectedState: { CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected($0) }, + clearConfiguration: { KeychainCacheStore.clearResult(key: self.key) }) + } + + static func clearUnserialized( + isDisconnected: () -> Bool, + setDisconnectedState: (Bool) -> Bool, + clearConfiguration: () -> KeychainCacheStore.ClearResult) -> Bool + { + let wasDisconnected = isDisconnected() + guard wasDisconnected || setDisconnectedState(true) else { return false } - guard KeychainCacheStore.clear(key: self.key) else { + + switch clearConfiguration() { + case .removed, .missing: + return true + case .failed: if !wasDisconnected { - _ = CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected(false) + _ = setDisconnectedState(false) } return false } - return true } } diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index 527b8f54e3..46d3f08f9c 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -881,6 +881,48 @@ struct CLIProxyAPIUsageCacheTests { } } +extension CLIProxyAPIUsageCacheTests { + @Test + func `configuration removal accepts a credential removed after its snapshot`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-concurrent-removal-\(UUID().uuidString)", isDirectory: true) + let costUsage = root.appendingPathComponent("cost-usage", isDirectory: true) + let usageFile = costUsage.appendingPathComponent(CostUsageCacheLocations.cliProxyAPIUsageFileName) + try fileManager.createDirectory(at: costUsage, withIntermediateDirectories: true) + try Data("telemetry".utf8).write(to: usageFile) + defer { try? fileManager.removeItem(at: root) } + let existing = CLIProxyAPIConnectionSettings(managementKey: "test-management-key") + let disconnected = LockIsolated(false) + + let result = CLIProxyAPIConnectionSettingsStore.removeAndPurgeTelemetry( + in: [costUsage], + stateRoot: root, + fileManager: fileManager, + operations: .init( + isDisconnected: { disconnected.value }, + loadStored: { .found(existing) }, + clearConfiguration: { + CLIProxyAPIConnectionSettingsStore.clearUnserialized( + isDisconnected: { disconnected.value }, + setDisconnectedState: { value in + disconnected.setValue(value) + return true + }, + clearConfiguration: { .missing }) + }, + setDisconnectedState: { value in + disconnected.setValue(value) + return true + }, + restore: { _ in true })) + + #expect(result == .removed) + #expect(disconnected.value) + #expect(!fileManager.fileExists(atPath: usageFile.path)) + } +} + struct CLIProxyAPITransactionRecoveryTests { @Test func `interrupted committed save clears its isolation marker on the next lock`() throws { From 06312e5e53bee9a0e717fa57bed94f8c6928719a Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 4 Aug 2026 04:48:36 -0700 Subject: [PATCH 103/183] Preserve proxy attribution boundaries --- Sources/CodexBar/ShareStatsPayload.swift | 23 ++++++++- .../CLIProxyAPIUsageTelemetry.swift | 2 +- .../CLIProxyAPIUsageCacheTests.swift | 49 +++++++++++++++++++ Tests/CodexBarTests/ShareStatsTests.swift | 46 +++++++++++++++++ 4 files changed, 117 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBar/ShareStatsPayload.swift b/Sources/CodexBar/ShareStatsPayload.swift index 9614c3c322..b468f650c4 100644 --- a/Sources/CodexBar/ShareStatsPayload.swift +++ b/Sources/CodexBar/ShareStatsPayload.swift @@ -339,11 +339,12 @@ enum ShareStatsBuilder { group.models.compactMap { row -> ShareStatsModelPayload? in let estimatedCost = self.finiteCost(row.totalCost) guard let modelName = ShareStatsSanitizer.modelName(row.modelName), + let sharedProvider = self.sharedModelProvider(for: row), row.totalTokens != nil else { return nil } return ShareStatsModelPayload( - provider: row.provider, - providerName: row.providerName, + provider: sharedProvider.provider, + providerName: sharedProvider.name, modelName: modelName, currencyCode: group.currencyCode, totalTokens: row.totalTokens, @@ -394,6 +395,24 @@ enum ShareStatsBuilder { return payload.hasShareableData ? payload : nil } + private static func sharedModelProvider( + for row: SpendDashboardModel.ModelRow) -> (provider: UsageProvider, name: String)? + { + guard row.attribution?.route == .cliProxyAPI else { + return (row.provider, row.providerName) + } + guard let upstream = row.attribution?.upstream else { return nil } + let normalizedProvider = upstream.provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let provider: UsageProvider? = switch normalizedProvider { + case "anthropic": .claude + case "aistudio", "gemini-interactions", "google": .gemini + case "vertex": .vertexai + default: UsageProvider(rawValue: normalizedProvider) + } + guard let provider else { return nil } + return (provider, ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName) + } + private static func finiteCost(_ value: Double?) -> Double? { guard let value, value.isFinite, value >= 0 else { return nil } return value diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 64ba7a0252..45aaebd3c8 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -783,7 +783,7 @@ public enum CLIProxyAPIConnectionSettingsStore { } func rollback() { - _ = operations.restore(storedSettings) + guard operations.restore(storedSettings) else { return } _ = operations.setDisconnectedState(wasDisconnected) if let artifactsUpdate { _ = CostUsageCacheLocations.restoreCLIProxyAPIArtifactsUpdate( diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index 46d3f08f9c..2eb157d577 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -882,6 +882,55 @@ struct CLIProxyAPIUsageCacheTests { } extension CLIProxyAPIUsageCacheTests { + @Test + func `failed credential rollback keeps replacement telemetry isolated`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-credential-rollback-failure-\(UUID().uuidString)", isDirectory: true) + let costUsage = root.appendingPathComponent("cost-usage", isDirectory: true) + let usageFile = costUsage.appendingPathComponent(CostUsageCacheLocations.cliProxyAPIUsageFileName) + try fileManager.createDirectory(at: costUsage, withIntermediateDirectories: true) + try Data("telemetry".utf8).write(to: usageFile) + defer { try? fileManager.removeItem(at: root) } + let existing = CLIProxyAPIConnectionSettings(managementKey: "old-management-key") + let replacement = CLIProxyAPIConnectionSettings(managementKey: "new-management-key") + let storedSettings = LockIsolated(existing) + let disconnected = LockIsolated(false) + let didAttemptRestore = LockIsolated(false) + + let saved = CLIProxyAPIConnectionSettingsStore.saveSerialized( + replacement, + artifactDirectories: [costUsage], + stateRoot: root, + fileManager: fileManager, + operations: .init( + isDisconnected: { disconnected.value }, + loadStored: { .found(storedSettings.value) }, + store: { settings in + storedSettings.setValue(settings) + return true + }, + setDisconnectedState: { value in + guard value else { return false } + disconnected.setValue(true) + return true + }, + restore: { _ in + didAttemptRestore.setValue(true) + return false + })) + + #expect(!saved) + #expect(didAttemptRestore.value) + #expect(storedSettings.value == replacement) + #expect(disconnected.value) + #expect(!fileManager.fileExists(atPath: usageFile.path)) + #expect(fileManager.fileExists( + atPath: root.appendingPathComponent("cliproxyapi-artifacts-transaction-v1.json").path)) + #expect(try fileManager.contentsOfDirectory(at: costUsage, includingPropertiesForKeys: nil) + .contains { $0.lastPathComponent.hasSuffix("replacement-backup") }) + } + @Test func `configuration removal accepts a credential removed after its snapshot`() throws { let fileManager = FileManager.default diff --git a/Tests/CodexBarTests/ShareStatsTests.swift b/Tests/CodexBarTests/ShareStatsTests.swift index 077b66a587..401f11f6dd 100644 --- a/Tests/CodexBarTests/ShareStatsTests.swift +++ b/Tests/CodexBarTests/ShareStatsTests.swift @@ -76,6 +76,52 @@ struct ShareStatsTests { #expect(payload.topModels.first?.estimatedCost == 1) } + @Test + func `proxy attributed models share under their verified upstream provider`() throws { + let attribution = CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .google, + upstream: .init(provider: "gemini", authType: .oauth), + evidence: [.cliProxyRequestLog, .cliProxyUsageTelemetry, .modelProvider]) + let group = SpendDashboardModel.CurrencyGroup( + currencyCode: "USD", + providers: [ + SpendDashboardModel.ProviderRow( + id: "claude", + rank: 1, + provider: .claude, + displayName: "Claude", + totalTokens: 100, + totalCost: 1, + coveredDayCount: 1), + ], + models: [ + SpendDashboardModel.ModelRow( + rank: 1, + provider: .claude, + providerName: "Claude", + modelName: "gemini-3-pro", + totalTokens: 100, + totalCost: 1, + attribution: attribution), + ], + dailyPoints: [], + totalTokens: 100, + totalCost: 1, + coveredDayCount: 1, + chartDomain: Self.date...Self.date, + modelHistoryCompleteness: .complete) + + let payload = try #require(ShareStatsBuilder.make( + model: SpendDashboardModel(requestedDays: 1, groups: [group]))) + let model = try #require(payload.topModels.first) + + #expect(model.provider == .gemini) + #expect(model.providerName == "Gemini") + #expect(model.modelName == "Gemini") + } + @Test func `builder preserves native currencies and unavailable spend`() throws { let subscriptionNames = try [ From 1a66672e28d7e7eabd25ff2e885a5c14a06f4b20 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 4 Aug 2026 16:03:28 -0700 Subject: [PATCH 104/183] Preserve proxy rollback recovery --- .../CLIProxyAPIUsageTelemetry.swift | 19 +++- .../CostUsageCacheLocations.swift | 31 ++++- .../CLIProxyAPIUsageCacheTests.swift | 107 ++++++++++++++++++ 3 files changed, 151 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 45aaebd3c8..f4d632a0fb 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -783,8 +783,15 @@ public enum CLIProxyAPIConnectionSettingsStore { } func rollback() { - guard operations.restore(storedSettings) else { return } - _ = operations.setDisconnectedState(wasDisconnected) + if let artifactsUpdate { + guard CostUsageCacheLocations.markCLIProxyAPIArtifactsUpdateForRollback( + artifactsUpdate, + fileManager: fileManager) + else { return } + } + guard operations.restore(storedSettings), + operations.setDisconnectedState(wasDisconnected) + else { return } if let artifactsUpdate { _ = CostUsageCacheLocations.restoreCLIProxyAPIArtifactsUpdate( artifactsUpdate, @@ -914,8 +921,12 @@ public enum CLIProxyAPIConnectionSettingsStore { else { return .configurationRemovalFailed } func rollback() { - _ = operations.restore(snapshot.storedSettings) - _ = operations.setDisconnectedState(snapshot.wasDisconnected) + guard CostUsageCacheLocations.markCLIProxyAPIArtifactsUpdateForRollback( + artifactsUpdate, + fileManager: fileManager), + operations.restore(snapshot.storedSettings), + operations.setDisconnectedState(snapshot.wasDisconnected) + else { return } _ = CostUsageCacheLocations.restoreCLIProxyAPIArtifactsUpdate( artifactsUpdate, fileManager: fileManager) diff --git a/Sources/CodexBarCore/CostUsageCacheLocations.swift b/Sources/CodexBarCore/CostUsageCacheLocations.swift index 8bf977ac7e..31028a0c42 100644 --- a/Sources/CodexBarCore/CostUsageCacheLocations.swift +++ b/Sources/CodexBarCore/CostUsageCacheLocations.swift @@ -44,6 +44,7 @@ public enum CostUsageCacheLocations { let moves: [Move] let disconnectedStateAfterCommit: Bool? let disconnectedStateAfterRollback: Bool? + let forceRollback: Bool? } static let cliProxyAPIUsageFileName = "cliproxyapi-usage-v1.json" @@ -214,7 +215,8 @@ public enum CostUsageCacheLocations { .init(originalPath: $0.originalURL.path, stagedPath: $0.stagedURL.path) }, disconnectedStateAfterCommit: disconnectedStateAfterCommit, - disconnectedStateAfterRollback: disconnectedStateAfterRollback) + disconnectedStateAfterRollback: disconnectedStateAfterRollback, + forceRollback: nil) do { try fileManager.createDirectory( at: manifestURL.deletingLastPathComponent(), @@ -280,6 +282,30 @@ public enum CostUsageCacheLocations { return self.removeCLIProxyAPIArtifactsManifest(update.manifestURL, fileManager: fileManager) } + @discardableResult + static func markCLIProxyAPIArtifactsUpdateForRollback( + _ update: CLIProxyAPIArtifactsUpdate, + fileManager: FileManager) -> Bool + { + guard let manifestURL = update.manifestURL else { return true } + guard let data = try? Data(contentsOf: manifestURL), + let manifest = try? JSONDecoder().decode(CLIProxyAPIArtifactsTransactionManifest.self, from: data) + else { return false } + guard manifest.forceRollback != true else { return true } + let rollbackManifest = CLIProxyAPIArtifactsTransactionManifest( + expectedGeneration: manifest.expectedGeneration, + moves: manifest.moves, + disconnectedStateAfterCommit: manifest.disconnectedStateAfterCommit, + disconnectedStateAfterRollback: manifest.disconnectedStateAfterRollback, + forceRollback: true) + do { + try JSONEncoder().encode(rollbackManifest).write(to: manifestURL, options: [.atomic]) + return true + } catch { + return false + } + } + @discardableResult static func restoreCLIProxyAPIArtifactsUpdate( _ update: CLIProxyAPIArtifactsUpdate, @@ -338,7 +364,8 @@ public enum CostUsageCacheLocations { stagedURL: URL(fileURLWithPath: $0.stagedPath)) }, manifestURL: manifestURL) - let didCommit = self.cliProxyAPIConfigurationGeneration(stateRoot: stateRoot, fileManager: fileManager) == + let didCommit = manifest.forceRollback != true && + self.cliProxyAPIConfigurationGeneration(stateRoot: stateRoot, fileManager: fileManager) == manifest.expectedGeneration let disconnectedState = didCommit ? manifest.disconnectedStateAfterCommit diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index 2eb157d577..b7223c9c0c 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -882,6 +882,60 @@ struct CLIProxyAPIUsageCacheTests { } extension CLIProxyAPIUsageCacheTests { + @Test + func `failed save marker rollback retains recovery transaction`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-save-marker-rollback-failure-\(UUID().uuidString)", isDirectory: true) + let costUsage = root.appendingPathComponent("cost-usage", isDirectory: true) + let usageFile = costUsage.appendingPathComponent(CostUsageCacheLocations.cliProxyAPIUsageFileName) + try fileManager.createDirectory(at: costUsage, withIntermediateDirectories: true) + try Data("telemetry".utf8).write(to: usageFile) + defer { try? fileManager.removeItem(at: root) } + let existing = CLIProxyAPIConnectionSettings(managementKey: "old-management-key") + let replacement = CLIProxyAPIConnectionSettings(managementKey: "new-management-key") + let storedSettings = LockIsolated(existing) + let disconnected = LockIsolated(false) + + let saved = CLIProxyAPIConnectionSettingsStore.saveSerialized( + replacement, + artifactDirectories: [costUsage], + stateRoot: root, + fileManager: fileManager, + operations: .init( + isDisconnected: { disconnected.value }, + loadStored: { .found(storedSettings.value) }, + store: { settings in + storedSettings.setValue(settings) + return true + }, + setDisconnectedState: { value in + guard value else { return false } + disconnected.setValue(true) + return true + }, + restore: { snapshot in + guard case let .found(settings) = snapshot else { return false } + storedSettings.setValue(settings) + return true + })) + + #expect(!saved) + #expect(storedSettings.value == existing) + #expect(disconnected.value) + #expect(!fileManager.fileExists(atPath: usageFile.path)) + #expect(fileManager.fileExists( + atPath: root.appendingPathComponent("cliproxyapi-artifacts-transaction-v1.json").path)) + + try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( + stateRoot: root, + fileManager: fileManager) {} + + #expect(fileManager.fileExists(atPath: usageFile.path)) + #expect(!fileManager.fileExists( + atPath: root.appendingPathComponent("cliproxyapi-artifacts-transaction-v1.json").path)) + } + @Test func `failed credential rollback keeps replacement telemetry isolated`() throws { let fileManager = FileManager.default @@ -970,6 +1024,59 @@ extension CLIProxyAPIUsageCacheTests { #expect(disconnected.value) #expect(!fileManager.fileExists(atPath: usageFile.path)) } + + @Test + func `failed removal marker rollback retains recovery transaction`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-removal-marker-rollback-failure-\(UUID().uuidString)", isDirectory: true) + let costUsage = root.appendingPathComponent("cost-usage", isDirectory: true) + let usageFile = costUsage.appendingPathComponent(CostUsageCacheLocations.cliProxyAPIUsageFileName) + try fileManager.createDirectory(at: costUsage, withIntermediateDirectories: true) + try Data("telemetry".utf8).write(to: usageFile) + defer { try? fileManager.removeItem(at: root) } + let existing = CLIProxyAPIConnectionSettings(managementKey: "test-management-key") + let storedSettings = LockIsolated(existing) + let disconnected = LockIsolated(false) + + let result = CLIProxyAPIConnectionSettingsStore.removeAndPurgeTelemetry( + in: [costUsage], + stateRoot: root, + fileManager: fileManager, + operations: .init( + isDisconnected: { disconnected.value }, + loadStored: { .found(existing) }, + clearConfiguration: { + storedSettings.setValue(nil) + disconnected.setValue(true) + return false + }, + setDisconnectedState: { value in + guard value else { return false } + disconnected.setValue(true) + return true + }, + restore: { snapshot in + guard case let .found(settings) = snapshot else { return false } + storedSettings.setValue(settings) + return true + })) + + #expect(result == .configurationRemovalFailed) + #expect(storedSettings.value == existing) + #expect(disconnected.value) + #expect(!fileManager.fileExists(atPath: usageFile.path)) + #expect(fileManager.fileExists( + atPath: root.appendingPathComponent("cliproxyapi-artifacts-transaction-v1.json").path)) + + try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( + stateRoot: root, + fileManager: fileManager) {} + + #expect(fileManager.fileExists(atPath: usageFile.path)) + #expect(!fileManager.fileExists( + atPath: root.appendingPathComponent("cliproxyapi-artifacts-transaction-v1.json").path)) + } } struct CLIProxyAPITransactionRecoveryTests { From c694017bf68842a7ecfc1af15704155a9b24567b Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 4 Aug 2026 16:24:04 -0700 Subject: [PATCH 105/183] Journal credential rollback recovery --- .../CLIProxyAPIUsageTelemetry.swift | 14 +++++-- .../CostUsageCacheLocations.swift | 41 ++++++++++++++++++- .../CLIProxyAPIUsageCacheTests.swift | 12 ++++++ 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index f4d632a0fb..498dbd5a98 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -789,9 +789,14 @@ public enum CLIProxyAPIConnectionSettingsStore { fileManager: fileManager) else { return } } - guard operations.restore(storedSettings), - operations.setDisconnectedState(wasDisconnected) - else { return } + guard operations.restore(storedSettings) else { return } + if let artifactsUpdate { + guard CostUsageCacheLocations.markCLIProxyAPIArtifactsRollbackCredentialsRestored( + artifactsUpdate, + fileManager: fileManager) + else { return } + } + guard operations.setDisconnectedState(wasDisconnected) else { return } if let artifactsUpdate { _ = CostUsageCacheLocations.restoreCLIProxyAPIArtifactsUpdate( artifactsUpdate, @@ -925,6 +930,9 @@ public enum CLIProxyAPIConnectionSettingsStore { artifactsUpdate, fileManager: fileManager), operations.restore(snapshot.storedSettings), + CostUsageCacheLocations.markCLIProxyAPIArtifactsRollbackCredentialsRestored( + artifactsUpdate, + fileManager: fileManager), operations.setDisconnectedState(snapshot.wasDisconnected) else { return } _ = CostUsageCacheLocations.restoreCLIProxyAPIArtifactsUpdate( diff --git a/Sources/CodexBarCore/CostUsageCacheLocations.swift b/Sources/CodexBarCore/CostUsageCacheLocations.swift index 31028a0c42..875b9cf81d 100644 --- a/Sources/CodexBarCore/CostUsageCacheLocations.swift +++ b/Sources/CodexBarCore/CostUsageCacheLocations.swift @@ -45,6 +45,7 @@ public enum CostUsageCacheLocations { let disconnectedStateAfterCommit: Bool? let disconnectedStateAfterRollback: Bool? let forceRollback: Bool? + let rollbackCredentialsRestored: Bool? } static let cliProxyAPIUsageFileName = "cliproxyapi-usage-v1.json" @@ -216,7 +217,8 @@ public enum CostUsageCacheLocations { }, disconnectedStateAfterCommit: disconnectedStateAfterCommit, disconnectedStateAfterRollback: disconnectedStateAfterRollback, - forceRollback: nil) + forceRollback: nil, + rollbackCredentialsRestored: nil) do { try fileManager.createDirectory( at: manifestURL.deletingLastPathComponent(), @@ -297,7 +299,8 @@ public enum CostUsageCacheLocations { moves: manifest.moves, disconnectedStateAfterCommit: manifest.disconnectedStateAfterCommit, disconnectedStateAfterRollback: manifest.disconnectedStateAfterRollback, - forceRollback: true) + forceRollback: true, + rollbackCredentialsRestored: manifest.rollbackCredentialsRestored) do { try JSONEncoder().encode(rollbackManifest).write(to: manifestURL, options: [.atomic]) return true @@ -306,6 +309,32 @@ public enum CostUsageCacheLocations { } } + @discardableResult + static func markCLIProxyAPIArtifactsRollbackCredentialsRestored( + _ update: CLIProxyAPIArtifactsUpdate, + fileManager: FileManager) -> Bool + { + guard let manifestURL = update.manifestURL else { return true } + guard let data = try? Data(contentsOf: manifestURL), + let manifest = try? JSONDecoder().decode(CLIProxyAPIArtifactsTransactionManifest.self, from: data) + else { return false } + guard manifest.forceRollback == true else { return false } + guard manifest.rollbackCredentialsRestored != true else { return true } + let restoredManifest = CLIProxyAPIArtifactsTransactionManifest( + expectedGeneration: manifest.expectedGeneration, + moves: manifest.moves, + disconnectedStateAfterCommit: manifest.disconnectedStateAfterCommit, + disconnectedStateAfterRollback: manifest.disconnectedStateAfterRollback, + forceRollback: true, + rollbackCredentialsRestored: true) + do { + try JSONEncoder().encode(restoredManifest).write(to: manifestURL, options: [.atomic]) + return true + } catch { + return false + } + } + @discardableResult static func restoreCLIProxyAPIArtifactsUpdate( _ update: CLIProxyAPIArtifactsUpdate, @@ -364,6 +393,14 @@ public enum CostUsageCacheLocations { stagedURL: URL(fileURLWithPath: $0.stagedPath)) }, manifestURL: manifestURL) + if manifest.forceRollback == true, manifest.rollbackCredentialsRestored != true { + guard self.setCLIProxyAPIExplicitlyDisconnected( + true, + stateRoot: stateRoot, + fileManager: fileManager) + else { return false } + return self.discardCLIProxyAPIArtifactsUpdate(update, fileManager: fileManager) + } let didCommit = manifest.forceRollback != true && self.cliProxyAPIConfigurationGeneration(stateRoot: stateRoot, fileManager: fileManager) == manifest.expectedGeneration diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index b7223c9c0c..91ea41aaa0 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -983,6 +983,18 @@ extension CLIProxyAPIUsageCacheTests { atPath: root.appendingPathComponent("cliproxyapi-artifacts-transaction-v1.json").path)) #expect(try fileManager.contentsOfDirectory(at: costUsage, includingPropertiesForKeys: nil) .contains { $0.lastPathComponent.hasSuffix("replacement-backup") }) + + try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( + stateRoot: root, + fileManager: fileManager) {} + + #expect(storedSettings.value == replacement) + #expect(disconnected.value) + #expect(!fileManager.fileExists(atPath: usageFile.path)) + #expect(!fileManager.fileExists( + atPath: root.appendingPathComponent("cliproxyapi-artifacts-transaction-v1.json").path)) + #expect(try fileManager.contentsOfDirectory(at: costUsage, includingPropertiesForKeys: nil) + .allSatisfy { !$0.lastPathComponent.hasSuffix("replacement-backup") }) } @Test From 8441b9a65a36b03b6838623bf58ae520ed31f5a0 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sun, 9 Aug 2026 05:54:39 -0700 Subject: [PATCH 106/183] Invalidate cancelled proxy spend --- .../CodexBar/SpendDashboardController.swift | 1 + .../SpendDashboardCodexProxySourceTests.swift | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index 82c739f8de..a4762fc666 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -431,6 +431,7 @@ enum SpendDashboardSource { } catch is CancellationError { failedSourceIDs.formUnion(request.codexRequests.map { "codex:\($0.id)" }) failedSourceIDs.insert(Self.codexProxySourceID) + invalidatedSourceIDs.insert(Self.codexProxySourceID) return SpendDashboardLoadResult( inputs: [], failedSourceIDs: failedSourceIDs, diff --git a/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift b/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift index 1bbc54ca21..9fb5d3c586 100644 --- a/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift +++ b/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift @@ -80,6 +80,40 @@ struct SpendDashboardCodexProxySourceTests { #expect(result.inputs.map(\.id) == [SpendDashboardSource.codexProxySourceID]) } + @Test + func `cancelled proxy load invalidates retained proxy source`() async { + let now = Date(timeIntervalSince1970: 1_784_179_200) + let staleProxyInput = SpendDashboardModel.ProviderInput( + id: SpendDashboardSource.codexProxySourceID, + provider: .codex, + displayName: "Codex · CLIProxyAPI", + modelProviderName: "Codex", + snapshot: Self.snapshot(cost: 2, now: now)) + let request = SpendDashboardLoadRequest( + configuration: SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.claude.rawValue], + codexAccountIdentities: []), + capturedInputs: [staleProxyInput], + unavailableSourceIDs: [], + codexRequests: [], + now: now, + force: false) + let emptySnapshot = Self.snapshot(cost: 0, now: now) + + let result = await SpendDashboardSource.load( + request, + codexSnapshotLoader: { _ in + Issue.record("No account-scoped Codex snapshot should be requested.") + return emptySnapshot + }, + codexProxySnapshotLoader: { _ in throw CancellationError() }) + + #expect(result.inputs.isEmpty) + #expect(result.failedSourceIDs == [SpendDashboardSource.codexProxySourceID]) + #expect(result.invalidatedSourceIDs == [SpendDashboardSource.codexProxySourceID]) + } + private static func snapshot(cost: Double, now: Date) -> CostUsageTokenSnapshot { let entry = CostUsageDailyReport.Entry( date: "2026-07-15", From 5b12543b409bf18cf9222b860c2d205002f56806 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Fri, 21 Aug 2026 06:25:45 -0700 Subject: [PATCH 107/183] Preserve interrupted proxy removals --- .../CLIProxyAPIUsageTelemetry.swift | 14 +++- .../CostUsageCacheLocations.swift | 48 +++++++++++++- .../CLIProxyAPIUsageCacheTests.swift | 66 +++++++++++++++++++ 3 files changed, 122 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 498dbd5a98..8633c0886f 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -922,7 +922,8 @@ public enum CLIProxyAPIConnectionSettingsStore { in: directories, stateRoot: stateRoot, expectedGeneration: generationUpdate.generation, - fileManager: fileManager) + fileManager: fileManager, + removalCredentialsCleared: false) else { return .configurationRemovalFailed } func rollback() { @@ -947,12 +948,19 @@ public enum CLIProxyAPIConnectionSettingsStore { rollback() return .configurationRemovalFailed } - // Publish the telemetry invalidation before deleting credentials. Crash recovery can then only - // finalize the purge; it must never restore data after the integration has been removed. + // Publish the telemetry invalidation before deleting credentials. Recovery keeps an interrupted + // removal disconnected until credential deletion has also been recorded durably. guard operations.clearConfiguration() else { rollback() return .configurationRemovalFailed } + guard CostUsageCacheLocations.markCLIProxyAPIArtifactsRemovalCredentialsCleared( + artifactsUpdate, + fileManager: fileManager) + else { + rollback() + return .configurationRemovalFailed + } return CostUsageCacheLocations.discardCLIProxyAPIArtifactsUpdate( artifactsUpdate, fileManager: fileManager) ? .removed : .telemetryCleanupFailed diff --git a/Sources/CodexBarCore/CostUsageCacheLocations.swift b/Sources/CodexBarCore/CostUsageCacheLocations.swift index 998d9517e7..6e840c7058 100644 --- a/Sources/CodexBarCore/CostUsageCacheLocations.swift +++ b/Sources/CodexBarCore/CostUsageCacheLocations.swift @@ -46,6 +46,7 @@ public enum CostUsageCacheLocations { let disconnectedStateAfterRollback: Bool? let forceRollback: Bool? let rollbackCredentialsRestored: Bool? + let removalCredentialsCleared: Bool? } static let cliProxyAPIUsageFileName = "cliproxyapi-usage-v1.json" @@ -193,6 +194,7 @@ public enum CostUsageCacheLocations { fileManager: FileManager, disconnectedStateAfterCommit: Bool? = nil, disconnectedStateAfterRollback: Bool? = nil, + removalCredentialsCleared: Bool? = nil, prepareState: () -> Bool = { true }) -> CLIProxyAPIArtifactsUpdate? { let identifier = UUID().uuidString @@ -218,7 +220,8 @@ public enum CostUsageCacheLocations { disconnectedStateAfterCommit: disconnectedStateAfterCommit, disconnectedStateAfterRollback: disconnectedStateAfterRollback, forceRollback: nil, - rollbackCredentialsRestored: nil) + rollbackCredentialsRestored: nil, + removalCredentialsCleared: removalCredentialsCleared) do { try fileManager.createDirectory( at: manifestURL.deletingLastPathComponent(), @@ -300,7 +303,8 @@ public enum CostUsageCacheLocations { disconnectedStateAfterCommit: manifest.disconnectedStateAfterCommit, disconnectedStateAfterRollback: manifest.disconnectedStateAfterRollback, forceRollback: true, - rollbackCredentialsRestored: manifest.rollbackCredentialsRestored) + rollbackCredentialsRestored: manifest.rollbackCredentialsRestored, + removalCredentialsCleared: manifest.removalCredentialsCleared) do { try JSONEncoder().encode(rollbackManifest).write(to: manifestURL, options: [.atomic]) return true @@ -326,7 +330,8 @@ public enum CostUsageCacheLocations { disconnectedStateAfterCommit: manifest.disconnectedStateAfterCommit, disconnectedStateAfterRollback: manifest.disconnectedStateAfterRollback, forceRollback: true, - rollbackCredentialsRestored: true) + rollbackCredentialsRestored: true, + removalCredentialsCleared: manifest.removalCredentialsCleared) do { try JSONEncoder().encode(restoredManifest).write(to: manifestURL, options: [.atomic]) return true @@ -335,6 +340,35 @@ public enum CostUsageCacheLocations { } } + @discardableResult + static func markCLIProxyAPIArtifactsRemovalCredentialsCleared( + _ update: CLIProxyAPIArtifactsUpdate, + fileManager: FileManager) -> Bool + { + guard let manifestURL = update.manifestURL else { return true } + guard let data = try? Data(contentsOf: manifestURL), + let manifest = try? JSONDecoder().decode(CLIProxyAPIArtifactsTransactionManifest.self, from: data) + else { return false } + guard manifest.forceRollback != true else { return false } + guard manifest.removalCredentialsCleared == false else { + return manifest.removalCredentialsCleared == true + } + let clearedManifest = CLIProxyAPIArtifactsTransactionManifest( + expectedGeneration: manifest.expectedGeneration, + moves: manifest.moves, + disconnectedStateAfterCommit: manifest.disconnectedStateAfterCommit, + disconnectedStateAfterRollback: manifest.disconnectedStateAfterRollback, + forceRollback: manifest.forceRollback, + rollbackCredentialsRestored: manifest.rollbackCredentialsRestored, + removalCredentialsCleared: true) + do { + try JSONEncoder().encode(clearedManifest).write(to: manifestURL, options: [.atomic]) + return true + } catch { + return false + } + } + @discardableResult static func restoreCLIProxyAPIArtifactsUpdate( _ update: CLIProxyAPIArtifactsUpdate, @@ -404,6 +438,14 @@ public enum CostUsageCacheLocations { let didCommit = manifest.forceRollback != true && self.cliProxyAPIConfigurationGeneration(stateRoot: stateRoot, fileManager: fileManager) == manifest.expectedGeneration + if didCommit, manifest.removalCredentialsCleared == false { + guard self.setCLIProxyAPIExplicitlyDisconnected( + true, + stateRoot: stateRoot, + fileManager: fileManager) + else { return false } + return self.restoreCLIProxyAPIArtifactsUpdate(update, fileManager: fileManager) + } let disconnectedState = didCommit ? manifest.disconnectedStateAfterCommit : manifest.disconnectedStateAfterRollback diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index 18707d07b8..a7e9f5729a 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -1226,4 +1226,70 @@ struct CLIProxyAPITransactionRecoveryTests { #expect(artifactsUpdate.moves.allSatisfy { !fileManager.fileExists(atPath: $0.stagedURL.path) }) #expect(artifactsUpdate.manifestURL.map { !fileManager.fileExists(atPath: $0.path) } == true) } + + @Test + func `interrupted removal before credential deletion keeps telemetry isolated`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-removal-before-credentials-\(UUID().uuidString)", isDirectory: true) + let costUsage = root.appendingPathComponent("cost-usage", isDirectory: true) + let usageFile = costUsage.appendingPathComponent(CostUsageCacheLocations.cliProxyAPIUsageFileName) + try fileManager.createDirectory(at: costUsage, withIntermediateDirectories: true) + try Data("telemetry".utf8).write(to: usageFile) + defer { try? fileManager.removeItem(at: root) } + + let generationUpdate = try #require(CostUsageCacheLocations + .prepareCLIProxyAPIConfigurationGenerationUpdate(stateRoot: root, fileManager: fileManager)) + let artifactsUpdate = try #require(CostUsageCacheLocations.prepareCLIProxyAPIArtifactsUpdate( + in: [costUsage], + stateRoot: root, + expectedGeneration: generationUpdate.generation, + fileManager: fileManager, + removalCredentialsCleared: false)) + #expect(CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate( + generationUpdate, + fileManager: fileManager)) + + try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock(stateRoot: root, fileManager: fileManager) {} + + #expect(CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( + stateRoot: root, + fileManager: fileManager)) + #expect(fileManager.fileExists(atPath: usageFile.path)) + #expect(artifactsUpdate.moves.allSatisfy { !fileManager.fileExists(atPath: $0.stagedURL.path) }) + #expect(artifactsUpdate.manifestURL.map { !fileManager.fileExists(atPath: $0.path) } == true) + } + + @Test + func `interrupted removal after credential deletion finalizes telemetry purge`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-removal-after-credentials-\(UUID().uuidString)", isDirectory: true) + let costUsage = root.appendingPathComponent("cost-usage", isDirectory: true) + let usageFile = costUsage.appendingPathComponent(CostUsageCacheLocations.cliProxyAPIUsageFileName) + try fileManager.createDirectory(at: costUsage, withIntermediateDirectories: true) + try Data("telemetry".utf8).write(to: usageFile) + defer { try? fileManager.removeItem(at: root) } + + let generationUpdate = try #require(CostUsageCacheLocations + .prepareCLIProxyAPIConfigurationGenerationUpdate(stateRoot: root, fileManager: fileManager)) + let artifactsUpdate = try #require(CostUsageCacheLocations.prepareCLIProxyAPIArtifactsUpdate( + in: [costUsage], + stateRoot: root, + expectedGeneration: generationUpdate.generation, + fileManager: fileManager, + removalCredentialsCleared: false)) + #expect(CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate( + generationUpdate, + fileManager: fileManager)) + #expect(CostUsageCacheLocations.markCLIProxyAPIArtifactsRemovalCredentialsCleared( + artifactsUpdate, + fileManager: fileManager)) + + try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock(stateRoot: root, fileManager: fileManager) {} + + #expect(!fileManager.fileExists(atPath: usageFile.path)) + #expect(artifactsUpdate.moves.allSatisfy { !fileManager.fileExists(atPath: $0.stagedURL.path) }) + #expect(artifactsUpdate.manifestURL.map { !fileManager.fileExists(atPath: $0.path) } == true) + } } From 68c572034e31bb7d7e77e8ad32e126eb74349e69 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Fri, 21 Aug 2026 06:47:11 -0700 Subject: [PATCH 108/183] Finalize isolated proxy removals --- Sources/CodexBarCore/CostUsageCacheLocations.swift | 3 +++ Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift | 9 +++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBarCore/CostUsageCacheLocations.swift b/Sources/CodexBarCore/CostUsageCacheLocations.swift index 6e840c7058..27c2d088d2 100644 --- a/Sources/CodexBarCore/CostUsageCacheLocations.swift +++ b/Sources/CodexBarCore/CostUsageCacheLocations.swift @@ -439,6 +439,9 @@ public enum CostUsageCacheLocations { self.cliProxyAPIConfigurationGeneration(stateRoot: stateRoot, fileManager: fileManager) == manifest.expectedGeneration if didCommit, manifest.removalCredentialsCleared == false { + if self.isCLIProxyAPIExplicitlyDisconnected(stateRoot: stateRoot, fileManager: fileManager) { + return self.discardCLIProxyAPIArtifactsUpdate(update, fileManager: fileManager) + } guard self.setCLIProxyAPIExplicitlyDisconnected( true, stateRoot: stateRoot, diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index a7e9f5729a..b2c549b824 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -1261,10 +1261,10 @@ struct CLIProxyAPITransactionRecoveryTests { } @Test - func `interrupted removal after credential deletion finalizes telemetry purge`() throws { + func `interrupted removal after isolation finalizes telemetry purge`() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory - .appendingPathComponent("cliproxy-removal-after-credentials-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent("cliproxy-removal-after-isolation-\(UUID().uuidString)", isDirectory: true) let costUsage = root.appendingPathComponent("cost-usage", isDirectory: true) let usageFile = costUsage.appendingPathComponent(CostUsageCacheLocations.cliProxyAPIUsageFileName) try fileManager.createDirectory(at: costUsage, withIntermediateDirectories: true) @@ -1282,8 +1282,9 @@ struct CLIProxyAPITransactionRecoveryTests { #expect(CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate( generationUpdate, fileManager: fileManager)) - #expect(CostUsageCacheLocations.markCLIProxyAPIArtifactsRemovalCredentialsCleared( - artifactsUpdate, + #expect(CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected( + true, + stateRoot: root, fileManager: fileManager)) try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock(stateRoot: root, fileManager: fileManager) {} From 92ef1978dcd0e4da2a5c109138c0863836550a68 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Fri, 21 Aug 2026 07:17:50 -0700 Subject: [PATCH 109/183] Harden proxy generation boundaries --- .../CLIProxyAPIUsageTelemetry.swift | 33 ++++++- .../CostUsageCacheLocations.swift | 88 +++++++++++++++-- .../CostUsage/CostUsageScanner+Claude.swift | 29 +++++- .../CLIProxyAPIUsageCacheTests.swift | 97 +++++++++++++++++-- .../CostUsageScannerClaudeMemoTests.swift | 32 ++++++ 5 files changed, 264 insertions(+), 15 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 8633c0886f..e73e63c32d 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -923,6 +923,8 @@ public enum CLIProxyAPIConnectionSettingsStore { stateRoot: stateRoot, expectedGeneration: generationUpdate.generation, fileManager: fileManager, + disconnectedStateAfterRollback: snapshot.wasDisconnected, + removalIsolationPublished: false, removalCredentialsCleared: false) else { return .configurationRemovalFailed } @@ -948,8 +950,16 @@ public enum CLIProxyAPIConnectionSettingsStore { rollback() return .configurationRemovalFailed } - // Publish the telemetry invalidation before deleting credentials. Recovery keeps an interrupted - // removal disconnected until credential deletion has also been recorded durably. + guard operations.setDisconnectedState(true), + CostUsageCacheLocations.markCLIProxyAPIArtifactsRemovalIsolationPublished( + artifactsUpdate, + fileManager: fileManager) + else { + rollback() + return .configurationRemovalFailed + } + // Isolation is transaction-owned and durable before Keychain deletion. Recovery can now finish + // deletion without confusing a disconnect marker that predated this removal. guard operations.clearConfiguration() else { rollback() return .configurationRemovalFailed @@ -966,6 +976,25 @@ public enum CLIProxyAPIConnectionSettingsStore { fileManager: fileManager) ? .removed : .telemetryCleanupFailed } + static func recoverInterruptedRemovalUnserialized( + stateRoot: URL?, + fileManager: FileManager) -> Bool + { + self.clearUnserialized( + isDisconnected: { + CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( + stateRoot: stateRoot, + fileManager: fileManager) + }, + setDisconnectedState: { + CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected( + $0, + stateRoot: stateRoot, + fileManager: fileManager) + }, + clearConfiguration: { KeychainCacheStore.clearResult(key: self.key) }) + } + private static func clearUnserialized() -> Bool { self.clearUnserialized( isDisconnected: { CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected() }, diff --git a/Sources/CodexBarCore/CostUsageCacheLocations.swift b/Sources/CodexBarCore/CostUsageCacheLocations.swift index 27c2d088d2..c29f773873 100644 --- a/Sources/CodexBarCore/CostUsageCacheLocations.swift +++ b/Sources/CodexBarCore/CostUsageCacheLocations.swift @@ -46,6 +46,7 @@ public enum CostUsageCacheLocations { let disconnectedStateAfterRollback: Bool? let forceRollback: Bool? let rollbackCredentialsRestored: Bool? + let removalIsolationPublished: Bool? let removalCredentialsCleared: Bool? } @@ -115,11 +116,20 @@ public enum CostUsageCacheLocations { static func withCLIProxyAPIInterprocessLock( stateRoot: URL?, fileManager: FileManager = .default, + recoverRemovalConfiguration: (() -> Bool)? = nil, operation: () throws -> T) throws -> T { let descriptor = try self.acquireCLIProxyAPILock(stateRoot: stateRoot, fileManager: fileManager) defer { self.releaseCLIProxyAPILock(descriptor) } - guard self.recoverCLIProxyAPIArtifactsTransaction(stateRoot: stateRoot, fileManager: fileManager) else { + guard self.recoverCLIProxyAPIArtifactsTransaction( + stateRoot: stateRoot, + fileManager: fileManager, + recoverRemovalConfiguration: recoverRemovalConfiguration ?? { + CLIProxyAPIConnectionSettingsStore.recoverInterruptedRemovalUnserialized( + stateRoot: stateRoot, + fileManager: fileManager) + }) + else { throw CocoaError(.fileReadUnknown) } return try operation() @@ -128,11 +138,20 @@ public enum CostUsageCacheLocations { static func withCLIProxyAPIInterprocessLock( stateRoot: URL?, fileManager: FileManager = .default, + recoverRemovalConfiguration: (() -> Bool)? = nil, operation: () async throws -> T) async throws -> T { let descriptor = try self.acquireCLIProxyAPILock(stateRoot: stateRoot, fileManager: fileManager) defer { self.releaseCLIProxyAPILock(descriptor) } - guard self.recoverCLIProxyAPIArtifactsTransaction(stateRoot: stateRoot, fileManager: fileManager) else { + guard self.recoverCLIProxyAPIArtifactsTransaction( + stateRoot: stateRoot, + fileManager: fileManager, + recoverRemovalConfiguration: recoverRemovalConfiguration ?? { + CLIProxyAPIConnectionSettingsStore.recoverInterruptedRemovalUnserialized( + stateRoot: stateRoot, + fileManager: fileManager) + }) + else { throw CocoaError(.fileReadUnknown) } return try await operation() @@ -194,6 +213,7 @@ public enum CostUsageCacheLocations { fileManager: FileManager, disconnectedStateAfterCommit: Bool? = nil, disconnectedStateAfterRollback: Bool? = nil, + removalIsolationPublished: Bool? = nil, removalCredentialsCleared: Bool? = nil, prepareState: () -> Bool = { true }) -> CLIProxyAPIArtifactsUpdate? { @@ -221,6 +241,7 @@ public enum CostUsageCacheLocations { disconnectedStateAfterRollback: disconnectedStateAfterRollback, forceRollback: nil, rollbackCredentialsRestored: nil, + removalIsolationPublished: removalIsolationPublished, removalCredentialsCleared: removalCredentialsCleared) do { try fileManager.createDirectory( @@ -304,6 +325,7 @@ public enum CostUsageCacheLocations { disconnectedStateAfterRollback: manifest.disconnectedStateAfterRollback, forceRollback: true, rollbackCredentialsRestored: manifest.rollbackCredentialsRestored, + removalIsolationPublished: manifest.removalIsolationPublished, removalCredentialsCleared: manifest.removalCredentialsCleared) do { try JSONEncoder().encode(rollbackManifest).write(to: manifestURL, options: [.atomic]) @@ -331,6 +353,7 @@ public enum CostUsageCacheLocations { disconnectedStateAfterRollback: manifest.disconnectedStateAfterRollback, forceRollback: true, rollbackCredentialsRestored: true, + removalIsolationPublished: manifest.removalIsolationPublished, removalCredentialsCleared: manifest.removalCredentialsCleared) do { try JSONEncoder().encode(restoredManifest).write(to: manifestURL, options: [.atomic]) @@ -360,6 +383,7 @@ public enum CostUsageCacheLocations { disconnectedStateAfterRollback: manifest.disconnectedStateAfterRollback, forceRollback: manifest.forceRollback, rollbackCredentialsRestored: manifest.rollbackCredentialsRestored, + removalIsolationPublished: manifest.removalIsolationPublished, removalCredentialsCleared: true) do { try JSONEncoder().encode(clearedManifest).write(to: manifestURL, options: [.atomic]) @@ -369,6 +393,36 @@ public enum CostUsageCacheLocations { } } + @discardableResult + static func markCLIProxyAPIArtifactsRemovalIsolationPublished( + _ update: CLIProxyAPIArtifactsUpdate, + fileManager: FileManager) -> Bool + { + guard let manifestURL = update.manifestURL else { return true } + guard let data = try? Data(contentsOf: manifestURL), + let manifest = try? JSONDecoder().decode(CLIProxyAPIArtifactsTransactionManifest.self, from: data) + else { return false } + guard manifest.forceRollback != true else { return false } + guard manifest.removalIsolationPublished == false else { + return manifest.removalIsolationPublished == true + } + let isolatedManifest = CLIProxyAPIArtifactsTransactionManifest( + expectedGeneration: manifest.expectedGeneration, + moves: manifest.moves, + disconnectedStateAfterCommit: manifest.disconnectedStateAfterCommit, + disconnectedStateAfterRollback: manifest.disconnectedStateAfterRollback, + forceRollback: manifest.forceRollback, + rollbackCredentialsRestored: manifest.rollbackCredentialsRestored, + removalIsolationPublished: true, + removalCredentialsCleared: manifest.removalCredentialsCleared) + do { + try JSONEncoder().encode(isolatedManifest).write(to: manifestURL, options: [.atomic]) + return true + } catch { + return false + } + } + @discardableResult static func restoreCLIProxyAPIArtifactsUpdate( _ update: CLIProxyAPIArtifactsUpdate, @@ -411,7 +465,8 @@ public enum CostUsageCacheLocations { @discardableResult static func recoverCLIProxyAPIArtifactsTransaction( stateRoot: URL?, - fileManager: FileManager = .default) -> Bool + fileManager: FileManager = .default, + recoverRemovalConfiguration: (() -> Bool)? = nil) -> Bool { let manifestURL = self.cliProxyAPIArtifactsTransactionURL( stateRoot: stateRoot, @@ -438,10 +493,31 @@ public enum CostUsageCacheLocations { let didCommit = manifest.forceRollback != true && self.cliProxyAPIConfigurationGeneration(stateRoot: stateRoot, fileManager: fileManager) == manifest.expectedGeneration - if didCommit, manifest.removalCredentialsCleared == false { - if self.isCLIProxyAPIExplicitlyDisconnected(stateRoot: stateRoot, fileManager: fileManager) { - return self.discardCLIProxyAPIArtifactsUpdate(update, fileManager: fileManager) + if didCommit, manifest.removalIsolationPublished != nil, manifest.removalIsolationPublished != true { + guard self.setCLIProxyAPIExplicitlyDisconnected( + manifest.disconnectedStateAfterRollback ?? true, + stateRoot: stateRoot, + fileManager: fileManager) + else { return false } + return self.restoreCLIProxyAPIArtifactsUpdate(update, fileManager: fileManager) + } + if didCommit, manifest.removalIsolationPublished == true, manifest.removalCredentialsCleared != true { + let recoverRemovalConfiguration = recoverRemovalConfiguration ?? { + CLIProxyAPIConnectionSettingsStore.recoverInterruptedRemovalUnserialized( + stateRoot: stateRoot, + fileManager: fileManager) } + guard self.setCLIProxyAPIExplicitlyDisconnected( + true, + stateRoot: stateRoot, + fileManager: fileManager), + recoverRemovalConfiguration(), + self.markCLIProxyAPIArtifactsRemovalCredentialsCleared(update, fileManager: fileManager) + else { return false } + return self.discardCLIProxyAPIArtifactsUpdate(update, fileManager: fileManager) + } + // Recover transactions written before removal-isolation provenance was added conservatively. + if didCommit, manifest.removalIsolationPublished == nil, manifest.removalCredentialsCleared == false { guard self.setCLIProxyAPIExplicitlyDisconnected( true, stateRoot: stateRoot, diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index c7d708bfe1..4b275c1112 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -3,6 +3,25 @@ import Foundation extension CostUsageScanner { // MARK: - Claude + private final class ClaudeReportMemoHitObserverStore: @unchecked Sendable { + let observer: () -> Void + + init(observer: @escaping () -> Void) { + self.observer = observer + } + } + + @TaskLocal private static var claudeReportMemoHitObserverStore: ClaudeReportMemoHitObserverStore? + + static func withClaudeReportMemoHitObserverForTesting( + _ observer: @escaping () -> Void, + operation: () throws -> T) rethrows -> T + { + try self.$claudeReportMemoHitObserverStore.withValue(.init(observer: observer)) { + try operation() + } + } + private struct ClaudeTokens { let input: Int let cacheRead: Int @@ -1045,8 +1064,16 @@ extension CostUsageScanner { priorMemo.sourceInventory == sourceInventory, priorMemo.reportKey == reportKey { + self.claudeReportMemoHitObserverStore?.observer() try checkCancellation?() - return priorMemo.report + return try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( + stateRoot: options.cacheRoot) + { + guard CostUsageCacheLocations.cliProxyAPIConfigurationGeneration( + stateRoot: options.cacheRoot) == cliProxyAPIConfigurationGeneration + else { throw CancellationError() } + return priorMemo.report + } } var cache = CostUsageClaudeCacheIO.load( diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index b2c549b824..f8740cd54a 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -1228,10 +1228,10 @@ struct CLIProxyAPITransactionRecoveryTests { } @Test - func `interrupted removal before credential deletion keeps telemetry isolated`() throws { + func `interrupted removal before isolation restores telemetry and connection state`() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory - .appendingPathComponent("cliproxy-removal-before-credentials-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent("cliproxy-removal-before-isolation-\(UUID().uuidString)", isDirectory: true) let costUsage = root.appendingPathComponent("cost-usage", isDirectory: true) let usageFile = costUsage.appendingPathComponent(CostUsageCacheLocations.cliProxyAPIUsageFileName) try fileManager.createDirectory(at: costUsage, withIntermediateDirectories: true) @@ -1245,26 +1245,83 @@ struct CLIProxyAPITransactionRecoveryTests { stateRoot: root, expectedGeneration: generationUpdate.generation, fileManager: fileManager, + disconnectedStateAfterRollback: false, + removalIsolationPublished: false, removalCredentialsCleared: false)) #expect(CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate( generationUpdate, fileManager: fileManager)) - try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock(stateRoot: root, fileManager: fileManager) {} + let didRecoverConfiguration = LockIsolated(false) + try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( + stateRoot: root, + fileManager: fileManager) + { + didRecoverConfiguration.setValue(true) + return true + } operation: {} + + #expect(!CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( + stateRoot: root, + fileManager: fileManager)) + #expect(!didRecoverConfiguration.value) + #expect(fileManager.fileExists(atPath: usageFile.path)) + #expect(artifactsUpdate.moves.allSatisfy { !fileManager.fileExists(atPath: $0.stagedURL.path) }) + #expect(artifactsUpdate.manifestURL.map { !fileManager.fileExists(atPath: $0.path) } == true) + } + + @Test + func `preexisting isolation does not impersonate removal progress`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-removal-preexisting-isolation-\(UUID().uuidString)", isDirectory: true) + let costUsage = root.appendingPathComponent("cost-usage", isDirectory: true) + let usageFile = costUsage.appendingPathComponent(CostUsageCacheLocations.cliProxyAPIUsageFileName) + try fileManager.createDirectory(at: costUsage, withIntermediateDirectories: true) + try Data("telemetry".utf8).write(to: usageFile) + defer { try? fileManager.removeItem(at: root) } + #expect(CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected( + true, + stateRoot: root, + fileManager: fileManager)) + + let generationUpdate = try #require(CostUsageCacheLocations + .prepareCLIProxyAPIConfigurationGenerationUpdate(stateRoot: root, fileManager: fileManager)) + let artifactsUpdate = try #require(CostUsageCacheLocations.prepareCLIProxyAPIArtifactsUpdate( + in: [costUsage], + stateRoot: root, + expectedGeneration: generationUpdate.generation, + fileManager: fileManager, + disconnectedStateAfterRollback: true, + removalIsolationPublished: false, + removalCredentialsCleared: false)) + #expect(CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate( + generationUpdate, + fileManager: fileManager)) + + let didRecoverConfiguration = LockIsolated(false) + try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( + stateRoot: root, + fileManager: fileManager) + { + didRecoverConfiguration.setValue(true) + return true + } operation: {} #expect(CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( stateRoot: root, fileManager: fileManager)) + #expect(!didRecoverConfiguration.value) #expect(fileManager.fileExists(atPath: usageFile.path)) #expect(artifactsUpdate.moves.allSatisfy { !fileManager.fileExists(atPath: $0.stagedURL.path) }) #expect(artifactsUpdate.manifestURL.map { !fileManager.fileExists(atPath: $0.path) } == true) } @Test - func `interrupted removal after isolation finalizes telemetry purge`() throws { + func `transaction owned isolation completes interrupted credential removal`() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory - .appendingPathComponent("cliproxy-removal-after-isolation-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent("cliproxy-removal-owned-isolation-\(UUID().uuidString)", isDirectory: true) let costUsage = root.appendingPathComponent("cost-usage", isDirectory: true) let usageFile = costUsage.appendingPathComponent(CostUsageCacheLocations.cliProxyAPIUsageFileName) try fileManager.createDirectory(at: costUsage, withIntermediateDirectories: true) @@ -1278,6 +1335,8 @@ struct CLIProxyAPITransactionRecoveryTests { stateRoot: root, expectedGeneration: generationUpdate.generation, fileManager: fileManager, + disconnectedStateAfterRollback: false, + removalIsolationPublished: false, removalCredentialsCleared: false)) #expect(CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate( generationUpdate, @@ -1286,9 +1345,35 @@ struct CLIProxyAPITransactionRecoveryTests { true, stateRoot: root, fileManager: fileManager)) + #expect(CostUsageCacheLocations.markCLIProxyAPIArtifactsRemovalIsolationPublished( + artifactsUpdate, + fileManager: fileManager)) - try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock(stateRoot: root, fileManager: fileManager) {} + let didRecoverConfiguration = LockIsolated(false) + #expect(throws: CocoaError.self) { + try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( + stateRoot: root, + fileManager: fileManager) + { + didRecoverConfiguration.setValue(true) + return false + } operation: {} + } + #expect(didRecoverConfiguration.value) + #expect(!fileManager.fileExists(atPath: usageFile.path)) + #expect(artifactsUpdate.moves.allSatisfy { fileManager.fileExists(atPath: $0.stagedURL.path) }) + #expect(artifactsUpdate.manifestURL.map { fileManager.fileExists(atPath: $0.path) } == true) + + didRecoverConfiguration.setValue(false) + try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( + stateRoot: root, + fileManager: fileManager) + { + didRecoverConfiguration.setValue(true) + return true + } operation: {} + #expect(didRecoverConfiguration.value) #expect(!fileManager.fileExists(atPath: usageFile.path)) #expect(artifactsUpdate.moves.allSatisfy { !fileManager.fileExists(atPath: $0.stagedURL.path) }) #expect(artifactsUpdate.manifestURL.map { !fileManager.fileExists(atPath: $0.path) } == true) diff --git a/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift b/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift index 7c90f7e9d2..d97421a4d7 100644 --- a/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift @@ -23,6 +23,38 @@ struct CostUsageScannerClaudeMemoTests { #expect(CostUsageClaudeFileStamp.read(at: cacheURL) == cacheStamp) } + @Test + func `memo hit rejects a proxy generation change after capture`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 1) + _ = try self.writeEvent(env: env, day: day, path: "project/session.jsonl", id: "first", input: 10) + let options = self.options(env: env) + _ = self.load(day: day, options: options) + + #expect(throws: CancellationError.self) { + _ = try CostUsageScanner.withClaudeReportMemoHitObserverForTesting { + let generationUpdate = CostUsageCacheLocations.prepareCLIProxyAPIConfigurationGenerationUpdate( + stateRoot: env.cacheRoot, + fileManager: .default) + #expect(generationUpdate != nil) + if let generationUpdate { + #expect(CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate( + generationUpdate, + fileManager: .default)) + } + } operation: { + try CostUsageScanner.loadDailyReportCancellable( + provider: .claude, + since: day, + until: day, + now: day, + options: options, + checkCancellation: nil) + } + } + } + @Test func `cold process reuses unchanged files from the persisted cache`() throws { let env = try CostUsageTestEnvironment() From 4aa1f97a9aef22843061bced7d3eb037539194de Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Fri, 21 Aug 2026 08:03:55 -0700 Subject: [PATCH 110/183] Honor proxy scan boundaries --- .../CLIProxyAPIUsageTelemetry.swift | 42 ++++++---- Sources/CodexBarCore/CostUsageFetcher.swift | 6 +- .../CLIProxyAPIUsageCacheTests.swift | 12 +++ ...UsageFetcherCachedProxyTimeZoneTests.swift | 78 +++++++++++++++++++ 4 files changed, 119 insertions(+), 19 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index e73e63c32d..34ffe0e67b 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -693,14 +693,10 @@ public enum CLIProxyAPIConnectionSettingsStore { store: { KeychainCacheStore.storeResult(key: self.key, entry: $0) }, setDisconnectedState: { CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected($0) }, restore: { storedSettings in - switch storedSettings { - case let .found(previousSettings): - KeychainCacheStore.storeResult(key: self.key, entry: previousSettings) - case .missing: - KeychainCacheStore.clear(key: self.key) - case .unavailable: - false - } + self.restoreStoredSettings( + storedSettings, + store: { KeychainCacheStore.storeResult(key: self.key, entry: $0) }, + clear: { KeychainCacheStore.clearResult(key: self.key) }) })) } @@ -714,6 +710,24 @@ public enum CLIProxyAPIConnectionSettingsStore { } } + static func restoreStoredSettings( + _ storedSettings: StoredSettingsSnapshot, + store: (CLIProxyAPIConnectionSettings) -> Bool, + clear: () -> KeychainCacheStore.ClearResult) -> Bool + { + switch storedSettings { + case let .found(previousSettings): + store(previousSettings) + case .missing: + switch clear() { + case .removed, .missing: true + case .failed: false + } + case .unavailable: + false + } + } + static func artifactDisposition( _ settings: CLIProxyAPIConnectionSettings, isDisconnected: Bool, @@ -862,14 +876,10 @@ public enum CLIProxyAPIConnectionSettingsStore { clearConfiguration: { self.clearUnserialized() }, setDisconnectedState: { CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected($0) }, restore: { storedSettings in - switch storedSettings { - case let .found(previousSettings): - KeychainCacheStore.storeResult(key: self.key, entry: previousSettings) - case .missing: - KeychainCacheStore.clear(key: self.key) - case .unavailable: - false - } + self.restoreStoredSettings( + storedSettings, + store: { KeychainCacheStore.storeResult(key: self.key, entry: $0) }, + clear: { KeychainCacheStore.clearResult(key: self.key) }) })) } diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 5a7c8ae675..6ec8e3d05a 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -1410,11 +1410,11 @@ extension CostUsageFetcher { options.refreshMinIntervalSeconds = 0 } - let scanOptions = options + let scan = options let proxyDaily = try await CostUsageScanExecutor.run { checkCancellation in - let range = CostUsageScanner.CostUsageDayRange(since: since, until: request.now) + let range = CostUsageScanner.CostUsageDayRange(since: since, until: request.now, calendar: scan.calendar) let supplemental = try Self.loadCodexSupplementalScan( - options: scanOptions, + options: scan, range: range, now: request.now, includeClaudeProxy: true, diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index f8740cd54a..7d80de265e 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -882,6 +882,18 @@ struct CLIProxyAPIUsageCacheTests { } extension CLIProxyAPIUsageCacheTests { + @Test + func `rollback accepts an already missing prior credential`() { + #expect(CLIProxyAPIConnectionSettingsStore.restoreStoredSettings( + .missing, + store: { _ in false }, + clear: { .missing })) + #expect(!CLIProxyAPIConnectionSettingsStore.restoreStoredSettings( + .missing, + store: { _ in false }, + clear: { .failed })) + } + @Test func `failed save marker rollback retains recovery transaction`() throws { let fileManager = FileManager.default diff --git a/Tests/CodexBarTests/CostUsageFetcherCachedProxyTimeZoneTests.swift b/Tests/CodexBarTests/CostUsageFetcherCachedProxyTimeZoneTests.swift index b91aaf6fef..71e916fb7b 100644 --- a/Tests/CodexBarTests/CostUsageFetcherCachedProxyTimeZoneTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherCachedProxyTimeZoneTests.swift @@ -3,6 +3,84 @@ import Testing @testable import CodexBarCore struct CostUsageFetcherCachedProxyTimeZoneTests { + @Test + func `proxy snapshot uses the configured calendar at a day boundary`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "Asia/Shanghai")) + let day = try #require(calendar.date(from: DateComponents( + timeZone: calendar.timeZone, + year: 2026, + month: 7, + day: 24, + hour: 0, + minute: 30))) + _ = try env.writeClaudeProjectFile( + relativePath: "proxy/day-boundary.jsonl", + contents: env.jsonl([[ + "type": "assistant", + "timestamp": env.isoString(for: day), + "sessionId": "session-day-boundary", + "requestId": "request-day-boundary", + "message": [ + "id": "message-day-boundary", + "model": "claude-sonnet-4-6", + "usage": ["input_tokens": 100, "output_tokens": 5], + ], + ]])) + + let proxyHome = env.root.appendingPathComponent("cli-proxy-api", isDirectory: true) + let proxyLogs = proxyHome.appendingPathComponent("logs", isDirectory: true) + try FileManager.default.createDirectory(at: proxyLogs, withIntermediateDirectories: true) + try Data(#"{"type":"codex"}"#.utf8) + .write(to: proxyHome.appendingPathComponent("codex-auth.json")) + let proxyLog = """ + === REQUEST INFO === + URL: /v1/messages + Timestamp: \(env.isoString(for: day)) + === HEADERS === + X-Claude-Code-Session-Id: session-day-boundary + === REQUEST BODY === + {"model":"claude-sonnet-4-6"} + === API RESPONSE === + """ + try Data(proxyLog.utf8).write(to: proxyLogs.appendingPathComponent("request.log")) + CLIProxyAPIUsageCacheIO.merge( + [ + CLIProxyAPIUsageRecord( + timestamp: day, + provider: "codex", + executorType: "CodexExecutor", + model: "gpt-5.5", + alias: "claude-sonnet-4-6", + endpoint: "/v1/messages", + authType: "oauth", + requestID: "proxy-day-boundary", + tokens: .init(input: 100, output: 5, total: 105)), + ], + cacheRoot: env.cacheRoot, + now: day) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot, + cliProxyAPIHome: proxyHome) + options.calendar = calendar + let snapshot = try await CostUsageFetcher(scannerOptions: options).loadCodexProxyTokenSnapshot( + now: day, + forceRefresh: true, + historyDays: 1, + allowPricingRefresh: false, + refreshPricingInBackground: false) + + #expect(snapshot.daily.map(\.date) == ["2026-07-24"]) + let cache = CostUsageClaudeCacheIO.load(provider: .claude, cacheRoot: env.cacheRoot) + #expect(cache.timeZoneIdentifier == calendar.timeZone.identifier) + } + @Test func `cached codex snapshot rejects claude proxy cache from another time zone`() async throws { let env = try CostUsageTestEnvironment() From 0af9612db252ab8fe77caf358153f1cfa47e4ec3 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Fri, 21 Aug 2026 09:19:59 -0700 Subject: [PATCH 111/183] Bound proxy telemetry timestamps --- .../CLIProxyAPIUsageTelemetry.swift | 63 +++++++++++++------ .../CLIProxyAPIUsageCacheTests.swift | 34 ++++++++-- 2 files changed, 74 insertions(+), 23 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 34ffe0e67b..c3725c7179 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -142,6 +142,39 @@ struct CLIProxyAPIUsageRecord: Codable, Equatable, Sendable { generate: self.generate, tokens: self.tokens) } + + func replacingTimestamp(_ timestamp: Date) -> Self { + Self( + timestamp: timestamp, + provider: self.provider, + executorType: self.executorType, + model: self.model, + alias: self.alias, + endpoint: self.endpoint, + authType: self.authType, + requestID: self.requestID, + localOccurrenceID: self.localOccurrenceID, + failed: self.failed, + generate: self.generate, + tokens: self.tokens) + } +} + +private enum CLIProxyAPIUsageRetention { + private static let maximumRecordAge: TimeInterval = 366 * 24 * 60 * 60 + private static let maximumFutureClockSkew: TimeInterval = 5 * 60 + + static func normalize( + _ records: [CLIProxyAPIUsageRecord], + now: Date) -> [CLIProxyAPIUsageRecord] + { + let cutoff = now.addingTimeInterval(-self.maximumRecordAge) + let futureCutoff = now.addingTimeInterval(self.maximumFutureClockSkew) + return records.compactMap { record in + guard record.timestamp >= cutoff, record.timestamp <= futureCutoff else { return nil } + return record.timestamp > now ? record.replacingTimestamp(now) : record + } + } } enum CLIProxyAPIUsageCacheIO { @@ -157,7 +190,6 @@ enum CLIProxyAPIUsageCacheIO { } private static let cacheLock = NSLock() - private static let maximumRecordAge: TimeInterval = 366 * 24 * 60 * 60 static func withExclusiveAccess(_ body: () throws -> T) rethrows -> T { try self.cacheLock.withLock(body) @@ -173,8 +205,7 @@ enum CLIProxyAPIUsageCacheIO { cacheRoot: cacheRoot, legacyCacheRoot: legacyCacheRoot) else { return false } - let cutoff = now.addingTimeInterval(-self.maximumRecordAge) - let retainedCache = Cache(records: currentCache.records.filter { $0.timestamp >= cutoff }) + let retainedCache = Cache(records: CLIProxyAPIUsageRetention.normalize(currentCache.records, now: now)) return retainedCache == currentCache || self.save(retainedCache, cacheRoot: cacheRoot) } } @@ -196,13 +227,12 @@ enum CLIProxyAPIUsageCacheIO { now: Date = Date()) -> [CLIProxyAPIUsageRecord] { let legacyCacheRoot = cacheRoot == nil ? self.defaultLegacyCacheRoot() : nil - let cutoff = now.addingTimeInterval(-self.maximumRecordAge) return self.withExclusiveAccess { guard let currentCache = self.loadCache( cacheRoot: cacheRoot, legacyCacheRoot: legacyCacheRoot) else { return [] } - let retainedRecords = currentCache.records.filter { $0.timestamp >= cutoff } + let retainedRecords = CLIProxyAPIUsageRetention.normalize(currentCache.records, now: now) let retainedCache = Cache(records: retainedRecords) if retainedCache != currentCache { _ = self.save(retainedCache, cacheRoot: cacheRoot) @@ -216,7 +246,6 @@ enum CLIProxyAPIUsageCacheIO { legacyCacheRoot: URL?, now: Date = Date()) -> [CLIProxyAPIUsageRecord] { - let cutoff = now.addingTimeInterval(-self.maximumRecordAge) if self.hasLegacyCacheToMigrate( cacheRoot: cacheRoot, legacyCacheRoot: legacyCacheRoot) @@ -230,7 +259,7 @@ enum CLIProxyAPIUsageCacheIO { cacheRoot: cacheRoot, legacyCacheRoot: legacyCacheRoot) else { return [] } - let retainedRecords = currentCache.records.filter { $0.timestamp >= cutoff } + let retainedRecords = CLIProxyAPIUsageRetention.normalize(currentCache.records, now: now) let retainedCache = Cache(records: retainedRecords) if retainedCache != currentCache { _ = self.save(retainedCache, cacheRoot: cacheRoot) @@ -242,7 +271,7 @@ enum CLIProxyAPIUsageCacheIO { return self.withExclusiveAccess { self.loadCache( cacheRoot: cacheRoot, - legacyCacheRoot: nil)?.records.filter { $0.timestamp >= cutoff } ?? [] + legacyCacheRoot: nil).map { CLIProxyAPIUsageRetention.normalize($0.records, now: now) } ?? [] } } } @@ -252,8 +281,8 @@ enum CLIProxyAPIUsageCacheIO { cacheRoot: cacheRoot, legacyCacheRoot: nil) else { return ([], false) } - let retainedRecords = existingCache.records.filter { $0.timestamp >= cutoff } - return (retainedRecords, retainedRecords.count != existingCache.records.count) + let retainedRecords = CLIProxyAPIUsageRetention.normalize(existingCache.records, now: now) + return (retainedRecords, retainedRecords != existingCache.records) } guard initialSnapshot.needsPruning else { return initialSnapshot.records } @@ -266,7 +295,7 @@ enum CLIProxyAPIUsageCacheIO { cacheRoot: cacheRoot, legacyCacheRoot: nil) else { return [] } - let retainedRecords = currentCache.records.filter { $0.timestamp >= cutoff } + let retainedRecords = CLIProxyAPIUsageRetention.normalize(currentCache.records, now: now) let retainedCache = Cache(records: retainedRecords) if retainedCache != currentCache { _ = self.save(retainedCache, cacheRoot: cacheRoot) @@ -301,14 +330,13 @@ enum CLIProxyAPIUsageCacheIO { now: Date = Date()) -> Int? { self.withExclusiveAccess { - let cutoff = now.addingTimeInterval(-self.maximumRecordAge) guard let existingCache = self.loadCache( cacheRoot: cacheRoot, legacyCacheRoot: legacyCacheRoot) else { return nil } - var byKey = self.recordsByKey(existingCache.records.filter { $0.timestamp >= cutoff }) + var byKey = self.recordsByKey(CLIProxyAPIUsageRetention.normalize(existingCache.records, now: now)) let priorCount = byKey.count - for (key, record) in self.recordsByKey(records.filter { $0.timestamp >= cutoff }) { + for (key, record) in self.recordsByKey(CLIProxyAPIUsageRetention.normalize(records, now: now)) { byKey[key] = record } let cache = Cache(records: byKey.values.sorted { $0.timestamp < $1.timestamp }) @@ -496,8 +524,6 @@ enum CLIProxyAPIUsagePendingIO { var records: [CLIProxyAPIUsageRecord] = [] } - private static let maximumRecordAge: TimeInterval = 366 * 24 * 60 * 60 - static func load( pendingRoot: URL? = nil, now: Date = Date()) -> [CLIProxyAPIUsageRecord]? @@ -508,9 +534,8 @@ enum CLIProxyAPIUsagePendingIO { let pendingBatch = try? self.decoder.decode(PendingBatch.self, from: data), pendingBatch.version == 1 else { return nil } - let cutoff = now.addingTimeInterval(-self.maximumRecordAge) - let retainedRecords = pendingBatch.records.filter { $0.timestamp >= cutoff } - if retainedRecords.count != pendingBatch.records.count { + let retainedRecords = CLIProxyAPIUsageRetention.normalize(pendingBatch.records, now: now) + if retainedRecords != pendingBatch.records { guard retainedRecords.isEmpty ? self.clear(pendingRoot: pendingRoot) : self.save(retainedRecords, pendingRoot: pendingRoot) diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index 7d80de265e..83220f44de 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -711,19 +711,20 @@ struct CLIProxyAPIUsageCacheTests { Self.record(id: "", timestamp: second.addingTimeInterval(0.1)), Self.record(id: "", timestamp: second.addingTimeInterval(0.9)), ] + let now = second.addingTimeInterval(1) #expect(CLIProxyAPIUsageCacheIO.merge( records, cacheRoot: root, - now: second) == 2) + now: now) == 2) #expect(CLIProxyAPIUsageCacheIO.merge( records, cacheRoot: root, - now: second) == 0) + now: now) == 0) let roundTripped = CLIProxyAPIUsageCacheIO.load( cacheRoot: root, - now: second) + now: now) #expect(roundTripped.count == 2) #expect( roundTripped.map { Int64($0.timestamp.timeIntervalSince1970 * 1000) } @@ -844,7 +845,7 @@ struct CLIProxyAPIUsageCacheTests { #expect(CLIProxyAPIUsageCacheIO.merge( records, cacheRoot: root, - now: records[0].timestamp) == 2) + now: records[1].timestamp) == 2) #expect(CLIProxyAPIUsageCollector.pruneExpiredUsage( cacheRoot: root, pendingRoot: root, @@ -882,6 +883,31 @@ struct CLIProxyAPIUsageCacheTests { } extension CLIProxyAPIUsageCacheTests { + @Test + func `usage retention clamps clock skew and rejects implausible future records`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-future-retention-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: root) } + let now = try #require(CostUsageDateParser.parse("2026-07-30T12:00:00Z")) + let records = [ + Self.record(id: "retained", timestamp: now.addingTimeInterval(-365 * 24 * 60 * 60)), + Self.record(id: "clock-skew", timestamp: now.addingTimeInterval(60)), + Self.record(id: "implausible-future", timestamp: now.addingTimeInterval(367 * 24 * 60 * 60)), + ] + + #expect(CLIProxyAPIUsageCacheIO.merge(records, cacheRoot: root, now: now) == 2) + let cached = CLIProxyAPIUsageCacheIO.load(cacheRoot: root, now: now) + #expect(cached.map(\.requestID) == ["retained", "clock-skew"]) + #expect(cached.last?.timestamp == now) + + #expect(CLIProxyAPIUsagePendingIO.save(records, pendingRoot: root)) + let pending = try #require(CLIProxyAPIUsagePendingIO.load(pendingRoot: root, now: now)) + #expect(pending.map(\.requestID) == ["retained", "clock-skew"]) + #expect(pending.last?.timestamp == now) + #expect(CLIProxyAPIUsagePendingIO.load(pendingRoot: root, now: now) == pending) + } + @Test func `rollback accepts an already missing prior credential`() { #expect(CLIProxyAPIConnectionSettingsStore.restoreStoredSettings( From 4fef1b328f9398ed4fce0d889fe9a59d6fa1c9c4 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Fri, 21 Aug 2026 09:41:06 -0700 Subject: [PATCH 112/183] Fix proxy retention test clocks --- .../CodexBarTests/CLIProxyAPIAttributionResolverTests.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift index a82967a3c7..b4b5e63a66 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift @@ -722,7 +722,7 @@ struct CLIProxyAPIAttributionResolverTests { #expect(CLIProxyAPIUsageCacheIO.merge( [expired, current], cacheRoot: cacheRoot, - now: expired.timestamp) == 2) + now: current.timestamp) == 2) let client = CLIProxyAPIUsageQueueClient( settings: .init(managementKey: "management-secret"), dataLoader: { request in @@ -759,14 +759,14 @@ struct CLIProxyAPIAttributionResolverTests { #expect(CLIProxyAPIUsageCacheIO.merge( [expired, current], cacheRoot: cacheRoot, - now: expired.timestamp) == 2) + now: current.timestamp) == 2) #expect(CLIProxyAPIUsageCacheIO.load( cacheRoot: cacheRoot, now: now).map(\.requestID) == [current.requestID]) #expect(CLIProxyAPIUsageCacheIO.load( cacheRoot: cacheRoot, - now: expired.timestamp).map(\.requestID) == [current.requestID]) + now: now.addingTimeInterval(1)).map(\.requestID) == [current.requestID]) } @Test From 57885c3baff16a0e2a0669c716e30837eaf8a4f7 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Fri, 21 Aug 2026 18:42:39 -0700 Subject: [PATCH 113/183] Invalidate stale proxy dashboard rows --- .../CodexBar/SpendDashboardController.swift | 15 ++++++- ...SpendDashboardSourceConcurrencyTests.swift | 41 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index b50500e0bd..d4ea49cdb3 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -11,6 +11,7 @@ struct SpendDashboardConfiguration: Equatable, Sendable { let providerIDs: [String] let codexAccountIdentities: [String] let codexAccountDisplayNames: [String: String] + let cliProxyAPIConfigurationGeneration: String? let sourceOwnershipFingerprints: [String] let sourceRevisions: [String] let bucketTimeZoneIdentifier: String @@ -25,6 +26,7 @@ struct SpendDashboardConfiguration: Equatable, Sendable { providerIDs: [String], codexAccountIdentities: [String], codexAccountDisplayNames: [String: String] = [:], + cliProxyAPIConfigurationGeneration: String? = nil, sourceOwnershipFingerprints: [String] = [], sourceRevisions: [String] = [], bucketTimeZoneIdentifier: String = "", @@ -38,6 +40,7 @@ struct SpendDashboardConfiguration: Equatable, Sendable { self.providerIDs = providerIDs self.codexAccountIdentities = codexAccountIdentities self.codexAccountDisplayNames = codexAccountDisplayNames + self.cliProxyAPIConfigurationGeneration = cliProxyAPIConfigurationGeneration self.sourceOwnershipFingerprints = sourceOwnershipFingerprints self.sourceRevisions = sourceRevisions self.bucketTimeZoneIdentifier = bucketTimeZoneIdentifier @@ -219,6 +222,10 @@ enum SpendDashboardSource { providerIDs: providers.map(\.rawValue), codexAccountIdentities: codexSources.map(\.identity), codexAccountDisplayNames: codexDisplayNames, + cliProxyAPIConfigurationGeneration: self.shouldLoadCodexProxy( + providerIDs: providers.map(\.rawValue)) + ? CostUsageCacheLocations.cliProxyAPIConfigurationGeneration() + : nil, sourceOwnershipFingerprints: self.sourceOwnershipFingerprints( providers: providers, settings: settings, @@ -1763,6 +1770,7 @@ final class SpendDashboardController { lhs.costUsageEnabled == rhs.costUsageEnabled && lhs.providerIDs == rhs.providerIDs && lhs.codexAccountIdentities == rhs.codexAccountIdentities && + lhs.cliProxyAPIConfigurationGeneration == rhs.cliProxyAPIConfigurationGeneration && lhs.sourceOwnershipFingerprints == rhs.sourceOwnershipFingerprints && lhs.bucketTimeZoneIdentifier == rhs.bucketTimeZoneIdentifier && lhs.openCodexUsageLogsEnabled == rhs.openCodexUsageLogsEnabled && @@ -1779,6 +1787,7 @@ final class SpendDashboardController { guard lhs.costUsageEnabled == rhs.costUsageEnabled, lhs.providerIDs == rhs.providerIDs, lhs.codexAccountIdentities == rhs.codexAccountIdentities, + lhs.cliProxyAPIConfigurationGeneration == rhs.cliProxyAPIConfigurationGeneration, lhs.sourceOwnershipFingerprints == rhs.sourceOwnershipFingerprints, lhs.sourceRevisions == rhs.sourceRevisions, lhs.bucketTimeZoneIdentifier == rhs.bucketTimeZoneIdentifier, @@ -1807,7 +1816,11 @@ final class SpendDashboardController { let changedCodexIDs = codexIDs.filter { previousCodexOwnership[$0] != currentCodexOwnership[$0] } - return Set(changedProviderIDs).union(changedCodexIDs) + var invalidatedSourceIDs = Set(changedProviderIDs).union(changedCodexIDs) + if previous.cliProxyAPIConfigurationGeneration != current.cliProxyAPIConfigurationGeneration { + invalidatedSourceIDs.insert(SpendDashboardSource.codexProxySourceID) + } + return invalidatedSourceIDs } private static func sourceOwnershipByID(_ fingerprints: [String]) -> [String: String] { diff --git a/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift b/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift index 8bfd9ed327..e5e725b6b0 100644 --- a/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift +++ b/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift @@ -101,6 +101,47 @@ struct SpendDashboardSourceConcurrencyTests { #expect(controller.failedSourceCount == 2) } + @Test + func `proxy generation change does not retain failed prior owner`() async { + let initial = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.claude.rawValue], + codexAccountIdentities: [], + cliProxyAPIConfigurationGeneration: "proxy-owner-a") + let replacement = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.claude.rawValue], + codexAccountIdentities: [], + cliProxyAPIConfigurationGeneration: "proxy-owner-b") + let requestSequence = SpendDashboardRequestSequence([ + .init(configuration: initial), + .init(configuration: replacement), + ]) + let gate = SpendDashboardResultBatchGate() + let controller = SpendDashboardController( + requestBuilder: { mode in await requestSequence.next(mode: mode) }, + loader: { request in await gate.load(request) }) + + controller.update(configuration: initial) + await Self.waitForResultGate(gate) + await gate.resume(result: SpendDashboardLoadResult( + inputs: [Self.input(id: SpendDashboardSource.codexProxySourceID, cost: 5)], + failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 5) + + controller.update(configuration: replacement) + await Self.waitForResultGate(gate) + #expect(controller.model.groups.isEmpty) + await gate.resume(result: SpendDashboardLoadResult( + inputs: [], + failedSourceIDs: [SpendDashboardSource.codexProxySourceID])) + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.model.groups.isEmpty) + #expect(controller.failedSourceCount == 1) + } + @Test func `Codex removal relabels retained failed account from second to first`() async throws { let gate = SpendDashboardResultBatchGate() From 6b413d3929479999896bb183417f041499d46f7b Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Fri, 21 Aug 2026 18:57:37 -0700 Subject: [PATCH 114/183] Refresh costs after proxy removal --- Sources/CodexBar/UsageStore+CLIProxyAPI.swift | 14 ++++++-- .../CLIProxyAPIUsageStoreTests.swift | 33 ++++++++++++++----- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift index 9fc4202a84..6d65ba346e 100644 --- a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift +++ b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift @@ -70,7 +70,8 @@ extension UsageStore { @discardableResult func removeCLIProxyAPIConfiguration( remove: (() async -> CLIProxyAPIConfigurationRemovalResult)? = nil, - scheduleCleanupRetry: (() -> Void)? = nil) async + scheduleCleanupRetry: (() -> Void)? = nil, + refresh: ((UsageProvider, Bool) async -> Void)? = nil) async -> CLIProxyAPIConfigurationRemovalResult { let collectorTask = self.stopCLIProxyAPIUsageCollector() @@ -85,7 +86,8 @@ extension UsageStore { }.value } if result != .configurationRemovalFailed { - self.invalidateCLIProxyAPICostAttribution() + self.invalidateCLIProxyAPICostAttribution(widgetReason: "cliproxyapi-disconnected") + await self.refreshCLIProxyAPIAffectedProviders(refresh: refresh) } if result == .telemetryCleanupFailed { if let scheduleCleanupRetry { @@ -182,7 +184,13 @@ extension UsageStore { refresh: ((UsageProvider, Bool) async -> Void)? = nil) async { self.invalidateCLIProxyAPICostAttribution(widgetReason: "cliproxyapi-reconnected") - // Provider-specific by design: reconnecting can change both sides of the Codex/Claude attribution split. + await self.refreshCLIProxyAPIAffectedProviders(refresh: refresh) + } + + private func refreshCLIProxyAPIAffectedProviders( + refresh: ((UsageProvider, Bool) async -> Void)?) async + { + // Provider-specific by design: connection changes can affect both sides of the Codex/Claude attribution split. for provider in [UsageProvider.claude, .codex] { if let refresh { await refresh(provider, true) diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift index 5e4d0d5f1b..1e513486cf 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift @@ -112,11 +112,16 @@ struct CLIProxyAPIUsageStoreTests { let claudePublicationRevision = store.tokenSnapshotPublicationRevision(for: .claude) let claudePublicationGuard = store.tokenRefreshPublicationGuard(for: .claude) let claudeScopeSignature = store.tokenSnapshotScopeSignature(for: .claude) + var refreshes: [(UsageProvider, Bool)] = [] - let removed = await store.removeCLIProxyAPIConfiguration { - collectorFinishedBeforePurge = collectorFinished.value - return .removed - } + let removed = await store.removeCLIProxyAPIConfiguration( + remove: { + collectorFinishedBeforePurge = collectorFinished.value + return .removed + }, + refresh: { provider, force in + refreshes.append((provider, force)) + }) await task.value #expect(removed == .removed) @@ -131,6 +136,8 @@ struct CLIProxyAPIUsageStoreTests { publicationGuard: claudePublicationGuard, historyDays: settings.costUsageHistoryDays, costScopeSignature: claudeScopeSignature)) + #expect(refreshes.map(\.0) == [.claude, .codex]) + #expect(refreshes.map(\.1) == [true, true]) #expect(await recorder.wasCancelled) } @@ -151,12 +158,16 @@ struct CLIProxyAPIUsageStoreTests { startupBehavior: .testing, environmentBase: environment) store.publishTokenSnapshot(Self.tokenSnapshot(), for: .codex) - let removed = await store.removeCLIProxyAPIConfiguration { - .configurationRemovalFailed - } + var refreshes: [(UsageProvider, Bool)] = [] + let removed = await store.removeCLIProxyAPIConfiguration( + remove: { .configurationRemovalFailed }, + refresh: { provider, force in + refreshes.append((provider, force)) + }) #expect(removed == .configurationRemovalFailed) #expect(store.tokenSnapshot(for: .codex) != nil) + #expect(refreshes.isEmpty) } @Test @@ -177,14 +188,20 @@ struct CLIProxyAPIUsageStoreTests { environmentBase: environment) store.publishTokenSnapshot(Self.tokenSnapshot(), for: .codex) var scheduledCleanupRetry = false + var refreshes: [(UsageProvider, Bool)] = [] let removed = await store.removeCLIProxyAPIConfiguration( remove: { .telemetryCleanupFailed }, - scheduleCleanupRetry: { scheduledCleanupRetry = true }) + scheduleCleanupRetry: { scheduledCleanupRetry = true }, + refresh: { provider, force in + refreshes.append((provider, force)) + }) #expect(removed == .telemetryCleanupFailed) #expect(store.tokenSnapshot(for: .codex) == nil) #expect(scheduledCleanupRetry) + #expect(refreshes.map(\.0) == [.claude, .codex]) + #expect(refreshes.map(\.1) == [true, true]) } @Test From 67a102c1bb339098d790e04c402ec9e79731e693 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Fri, 21 Aug 2026 19:29:13 -0700 Subject: [PATCH 115/183] Refresh spend dashboard gatekeeper anchors --- .../ProviderArchitectureGatekeeperTests.swift | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 0fd67b96fd..6fe648d9cf 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -896,55 +896,55 @@ struct ProviderArchitectureGatekeeperTests { reason: "This observation touchpoint reads a fixed provider field so UI invalidation tracks that setting."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 444, + line: 451, 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: 446, + line: 453, anchor: "modelProviderName: ProviderDescriptorRegistry.descriptor(for: .codex).metadata.displayName,", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 543, + line: 550, 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: 546, + line: 553, 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: 603, + line: 610, 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: 720, + line: 727, 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: 1672, + line: 1679, 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: 1701, + line: 1708, 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: 1718, + line: 1725, anchor: "if sourceID.hasPrefix(\"codex:\") { return .codex }", expectedProviderIDs: ["codex"], reason: "This publication projection maps stable Codex account source IDs back to their provider family."), @@ -2318,7 +2318,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 709, + line: 716, anchor: "(providers.contains(.codex) && settings.codexLocalSessionCostLedgerEnabled)", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2326,7 +2326,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct preserves the provider-owned local ledger when global scanning is off."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 193, + line: 196, anchor: "let codexSources = providers.contains(.codex)", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2334,7 +2334,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: 256, + line: 263, anchor: "let providerBaselines = initialProviders.filter { $0 != .codex }.map { provider in", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2342,7 +2342,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: 284, + line: 291, anchor: "let codexSources = providers.contains(.codex)", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2350,7 +2350,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: 307, + line: 314, anchor: "for provider in providers where provider != .codex {", expectedProviderIDs: ["codex", "grok"], expectedReferenceCount: 7, @@ -2358,7 +2358,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: 771, + line: 778, anchor: "if providers.contains(.codex) {", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2366,7 +2366,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: 799, + line: 806, anchor: "if providers.contains(.codex) {", expectedProviderIDs: ["codex"], expectedReferenceCount: 3, @@ -2374,7 +2374,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: 1745, + line: 1752, anchor: "guard input.provider == .codex,", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, From 3b1d6635d96362fb9b1baba130549d20f4b3e002 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Fri, 21 Aug 2026 19:56:38 -0700 Subject: [PATCH 116/183] Preserve account rows during proxy cancellation --- .../CodexBar/SpendDashboardController.swift | 4 +-- .../SpendDashboardCodexProxySourceTests.swift | 25 +++++++++++-------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index d4ea49cdb3..632b214bae 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -613,11 +613,11 @@ enum SpendDashboardSource { snapshot: snapshot)) } } catch is CancellationError { - failedSourceIDs.formUnion(request.codexRequests.map { "codex:\($0.id)" }) + inputs.removeAll { $0.id == Self.codexProxySourceID } failedSourceIDs.insert(Self.codexProxySourceID) invalidatedSourceIDs.insert(Self.codexProxySourceID) return SpendDashboardLoadResult( - inputs: [], + inputs: inputs, failedSourceIDs: failedSourceIDs, invalidatedSourceIDs: invalidatedSourceIDs) } catch { diff --git a/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift b/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift index 9fb5d3c586..7f3eb1c71e 100644 --- a/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift +++ b/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift @@ -81,8 +81,16 @@ struct SpendDashboardCodexProxySourceTests { } @Test - func `cancelled proxy load invalidates retained proxy source`() async { + func `cancelled proxy load preserves direct account and invalidates retained proxy source`() async { let now = Date(timeIntervalSince1970: 1_784_179_200) + let account = CodexSpendScanRequest( + id: "first", + displayName: "Codex · first", + source: .profileHome(path: "/synthetic/first"), + homePath: "/synthetic/first", + authFingerprint: nil, + authFileWasReadable: false, + cacheIdentity: "first-cache") let staleProxyInput = SpendDashboardModel.ProviderInput( id: SpendDashboardSource.codexProxySourceID, provider: .codex, @@ -92,24 +100,21 @@ struct SpendDashboardCodexProxySourceTests { let request = SpendDashboardLoadRequest( configuration: SpendDashboardConfiguration( costUsageEnabled: true, - providerIDs: [UsageProvider.claude.rawValue], - codexAccountIdentities: []), + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: ["\(account.id)|\(account.cacheIdentity)"]), capturedInputs: [staleProxyInput], unavailableSourceIDs: [], - codexRequests: [], + codexRequests: [account], now: now, force: false) - let emptySnapshot = Self.snapshot(cost: 0, now: now) + let accountSnapshot = Self.snapshot(cost: 1, now: now) let result = await SpendDashboardSource.load( request, - codexSnapshotLoader: { _ in - Issue.record("No account-scoped Codex snapshot should be requested.") - return emptySnapshot - }, + codexSnapshotLoader: { _ in accountSnapshot }, codexProxySnapshotLoader: { _ in throw CancellationError() }) - #expect(result.inputs.isEmpty) + #expect(result.inputs.map(\.id) == ["codex:first"]) #expect(result.failedSourceIDs == [SpendDashboardSource.codexProxySourceID]) #expect(result.invalidatedSourceIDs == [SpendDashboardSource.codexProxySourceID]) } From 67c87f3313646d77d6ff00df2135c0d1832d529d Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Fri, 21 Aug 2026 20:56:33 -0700 Subject: [PATCH 117/183] Refresh attribution after proxy imports --- Sources/CodexBar/UsageStore+CLIProxyAPI.swift | 5 +- .../CostUsage/CostUsageScanner+Claude.swift | 9 +++- ...Scanner+ClaudeCLIProxyAPITestSupport.swift | 21 ++++++++ .../CLIProxyAPIUsageStoreTests.swift | 39 +++++++++++++++ .../CostUsageScannerClaudeMemoTests.swift | 50 +++++++++++++++++++ 5 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+ClaudeCLIProxyAPITestSupport.swift diff --git a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift index 6d65ba346e..9b293e1e64 100644 --- a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift +++ b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift @@ -155,9 +155,10 @@ extension UsageStore { } collectorState.configurationAvailability = .unavailable collectorState.configurationGeneration = configurationGeneration() - case .collected: + case let .collected(count): let currentGeneration = configurationGeneration() - if collectorState.configurationAvailability == .unavailable || + if count > 0 || + collectorState.configurationAvailability == .unavailable || (collectorState.configurationGeneration != nil && collectorState.configurationGeneration != currentGeneration) { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 4b275c1112..50c0afa795 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -988,6 +988,7 @@ extension CostUsageScanner { private struct ClaudeCLIProxyAPIAttributionState { let configurationGeneration: String? let resolver: CLIProxyAPIAttributionResolver? + let usageArtifactStamp: CostUsageClaudeFileStamp? } private static func captureClaudeCLIProxyAPIAttributionState( @@ -1014,9 +1015,12 @@ extension CostUsageScanner { } else { attributionResolver = nil } + let usageArtifactStamp = CostUsageClaudeFileStamp.read( + at: CLIProxyAPIUsageCacheIO.cacheFileURL(cacheRoot: options.cacheRoot)) return ClaudeCLIProxyAPIAttributionState( configurationGeneration: configurationGeneration, - resolver: attributionResolver) + resolver: attributionResolver, + usageArtifactStamp: usageArtifactStamp) } } @@ -1031,8 +1035,10 @@ extension CostUsageScanner { let cliProxyAPIAttributionState = try self.captureClaudeCLIProxyAPIAttributionState( options: options, checkCancellation: checkCancellation) + self.claudeCLIProxyAPIAttributionCaptureObserverStore?.observer() let cliProxyAPIConfigurationGeneration = cliProxyAPIAttributionState.configurationGeneration let attributionResolver = cliProxyAPIAttributionState.resolver + let cliProxyUsageArtifactStamp = cliProxyAPIAttributionState.usageArtifactStamp let roots = self.defaultClaudeProjectsRoots(options: options) let inventory = try Self.inventoryClaudeRoots(roots, checkCancellation: checkCancellation) try checkCancellation?() @@ -1043,7 +1049,6 @@ extension CostUsageScanner { let pricingURL = ModelsDevCache.cacheFileURL(cacheRoot: options.cacheRoot) let pricingArtifactStamp = CostUsageClaudeFileStamp.read(at: pricingURL) let cliProxyUsageURL = CLIProxyAPIUsageCacheIO.cacheFileURL(cacheRoot: options.cacheRoot) - let cliProxyUsageArtifactStamp = CostUsageClaudeFileStamp.read(at: cliProxyUsageURL) let reportKey = Self.claudeReportMemoKey( provider: provider, providerFilter: options.claudeLogProviderFilter, diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+ClaudeCLIProxyAPITestSupport.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+ClaudeCLIProxyAPITestSupport.swift new file mode 100644 index 0000000000..664323fc1e --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+ClaudeCLIProxyAPITestSupport.swift @@ -0,0 +1,21 @@ +extension CostUsageScanner { + final class ClaudeCLIProxyAPIAttributionCaptureObserverStore: @unchecked Sendable { + let observer: () -> Void + + init(observer: @escaping () -> Void) { + self.observer = observer + } + } + + @TaskLocal static var claudeCLIProxyAPIAttributionCaptureObserverStore: + ClaudeCLIProxyAPIAttributionCaptureObserverStore? + + static func withClaudeCLIProxyAPIAttributionCaptureObserverForTesting( + _ observer: @escaping () -> Void, + operation: () throws -> T) rethrows -> T + { + try self.$claudeCLIProxyAPIAttributionCaptureObserverStore.withValue(.init(observer: observer)) { + try operation() + } + } +} diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift index 1e513486cf..5cf9155b59 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift @@ -421,6 +421,45 @@ struct CLIProxyAPIUsageStoreTests { #expect(refreshes.map(\.1) == [true, true]) } + @Test + func `new telemetry invalidates and refreshes proxy affected snapshots`() async { + let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") + settings.costUsageEnabled = true + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + let snapshot = Self.tokenSnapshot() + store.publishTokenSnapshot(snapshot, for: .codex) + store.publishTokenSnapshot(snapshot, for: .claude) + let codexPublicationRevision = store.tokenSnapshotPublicationRevision(for: .codex) + let claudePublicationRevision = store.tokenSnapshotPublicationRevision(for: .claude) + let dashboardRevision = store.spendDashboardCodexCostCatchUpRevision + var refreshes: [(UsageProvider, Bool)] = [] + + let collectorState = await store.handleCLIProxyAPIUsageCollectionResult( + .collected(1), + collectorState: CLIProxyAPIUsageCollectorState( + configurationAvailability: .available, + configurationGeneration: "current-generation"), + configurationGeneration: { "current-generation" }, + refresh: { provider, force in + refreshes.append((provider, force)) + }) + + #expect(collectorState.configurationAvailability == .available) + #expect(collectorState.configurationGeneration == "current-generation") + #expect(store.tokenSnapshot(for: .codex) == nil) + #expect(store.tokenSnapshotPublicationRevision(for: .codex) == codexPublicationRevision + 1) + #expect(store.tokenSnapshot(for: .claude) == nil) + #expect(store.tokenSnapshotPublicationRevision(for: .claude) == claudePublicationRevision + 1) + #expect(store.spendDashboardCodexCostCatchUpRevision == dashboardRevision + 1) + #expect(refreshes.map(\.0) == [.claude, .codex]) + #expect(refreshes.map(\.1) == [true, true]) + } + @Test func `failed generation transition stays pending until collection succeeds`() async { let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") diff --git a/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift b/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift index d97421a4d7..61ee78350c 100644 --- a/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift @@ -55,6 +55,56 @@ struct CostUsageScannerClaudeMemoTests { } } + @Test + func `telemetry appended after resolver capture is not memoized as stale attribution`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 1) + _ = try self.writeEvent( + env: env, + day: day, + path: "proxy/session.jsonl", + id: "proxy", + input: 100, + model: "gpt-5.5") + let cliProxyHome = env.root.appendingPathComponent("cli-proxy-api", isDirectory: true) + let cliProxyLogs = cliProxyHome.appendingPathComponent("logs", isDirectory: true) + try FileManager.default.createDirectory(at: cliProxyLogs, withIntermediateDirectories: true) + let requestLog = """ + === REQUEST INFO === + URL: /v1/messages + Timestamp: \(env.isoString(for: day)) + === HEADERS === + X-Claude-Code-Session-Id: session-proxy + === REQUEST BODY === + {"model":"gpt-5.5"} + === API RESPONSE === + """ + try Data(requestLog.utf8).write(to: cliProxyLogs.appendingPathComponent("request.log")) + var options = self.options(env: env) + options.cliProxyAPIHome = cliProxyHome + let usageRecord = CLIProxyAPIUsageRecord( + timestamp: day, + provider: "codex", + executorType: "CodexExecutor", + model: "gpt-5.5", + alias: "gpt-5.5", + endpoint: "/v1/messages", + authType: "oauth", + requestID: "proxy-request", + tokens: .init(input: 100, output: 0, total: 100)) + + let stale = CostUsageScanner.withClaudeCLIProxyAPIAttributionCaptureObserverForTesting { + #expect(CLIProxyAPIUsageCacheIO.merge([usageRecord], cacheRoot: env.cacheRoot, now: day) == 1) + } operation: { + self.load(day: day, options: options) + } + let refreshed = self.load(day: day, options: options) + + #expect(stale.data.first?.modelBreakdowns?.first?.attribution?.upstream == nil) + #expect(refreshed.data.first?.modelBreakdowns?.first?.attribution?.upstream?.provider == "codex") + } + @Test func `cold process reuses unchanged files from the persisted cache`() throws { let env = try CostUsageTestEnvironment() From a72ea1b0e6d5f19167869f47654e62c9ca3734dd Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Fri, 21 Aug 2026 21:37:21 -0700 Subject: [PATCH 118/183] Guard proxy refresh configuration --- .../CLIProxyAPIUsageTelemetry.swift | 28 ++++++++-- Sources/CodexBarCore/CostUsageFetcher.swift | 23 ++++++-- .../CLIProxyAPIUsageCollectorTests.swift | 53 +++++++++++++++++++ .../CodexBarTests/CostUsageFetcherTests.swift | 11 ++++ .../ProviderArchitectureGatekeeperTests.swift | 24 ++++----- 5 files changed, 119 insertions(+), 20 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index c3725c7179..f40b2a304e 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -1173,21 +1173,41 @@ public enum CLIProxyAPIUsageCollector { settings: CLIProxyAPIConnectionSettings?, shouldContinue: @escaping @Sendable () async -> Bool = { true }) async -> CLIProxyAPIUsageCollectionResult + { + await self.collect( + cacheRoot: cacheRoot, + settings: settings, + currentSettingsResult: { CLIProxyAPIConnectionSettingsStore.loadResult() }, + shouldContinue: shouldContinue) + } + + static func collect( + cacheRoot: URL? = nil, + settings: CLIProxyAPIConnectionSettings?, + currentSettingsResult: @escaping @Sendable () + -> KeychainCacheStore.LoadResult, + shouldContinue: @escaping @Sendable () async -> Bool = { true }, + client: CLIProxyAPIUsageQueueClient? = nil) async + -> CLIProxyAPIUsageCollectionResult { guard let settings, settings.isConfigured else { return .notConfigured } + let stateRoot = cacheRoot?.deletingLastPathComponent() + let configurationGeneration = CostUsageCacheLocations.cliProxyAPIConfigurationGeneration(stateRoot: stateRoot) return await self.collect( cacheRoot: cacheRoot, configurationIsCurrent: { - guard !CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected(stateRoot: cacheRoot) + guard !CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected(stateRoot: stateRoot) else { return false } - return switch CLIProxyAPIConnectionSettingsStore.loadResult() { + return switch currentSettingsResult() { case let .found(currentSettings): currentSettings == settings - case .temporarilyUnavailable: true + case .temporarilyUnavailable: + CostUsageCacheLocations.cliProxyAPIConfigurationGeneration(stateRoot: stateRoot) == + configurationGeneration case .missing, .invalid: false } }, shouldContinue: shouldContinue, - client: CLIProxyAPIUsageQueueClient(settings: settings)) + client: client ?? CLIProxyAPIUsageQueueClient(settings: settings)) } static func collect( diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index b614c872a7..570377deb6 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -229,10 +229,10 @@ public struct CostUsageFetcher: Sendable { bypassScannerDebounce: Bool, calendar: Calendar? = nil) async throws -> CostUsageTokenSnapshot { - var options = self.scannerOptionsOverride() ?? CostUsageScanner.Options() - if let calendar { - options.calendar = calendar - } + let options = self.resolvedTokenSnapshotScannerOptions( + provider: provider, + codexHomePath: codexHomePath, + calendar: calendar) return try await Self.loadTokenSnapshot( provider: provider, environment: environment, @@ -323,6 +323,21 @@ public struct CostUsageFetcher: Sendable { return options } + func resolvedTokenSnapshotScannerOptions( + provider: UsageProvider, + codexHomePath: String?, + calendar: Calendar?) -> CostUsageScanner.Options + { + var options = Self.resolvedScannerOptions( + self.scannerOptionsOverride(), + provider: provider, + codexHomePath: codexHomePath) + if let calendar { + options.calendar = calendar + } + return options + } + package func cliProxyAPIConfigurationGeneration() -> String? { let options = Self.resolvedScannerOptions( self.scannerOptionsOverride(), diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift index f5f921fc93..d5bdd6d0ec 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift @@ -282,6 +282,59 @@ struct CLIProxyAPIUsageCollectorTests { #expect(result == .failed("CLIProxyAPI configuration is temporarily unavailable.")) } + @Test + func `temporary credential failure rejects a replaced configuration after lock acquisition`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cliproxy-replaced-configuration-\(UUID().uuidString)", isDirectory: true) + let cacheRoot = root.appendingPathComponent("cost-usage", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let initialGeneration = try #require( + CostUsageCacheLocations.prepareCLIProxyAPIConfigurationGenerationUpdate(stateRoot: root)) + #expect(CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate(initialGeneration)) + let lockAcquired = DispatchSemaphore(value: 0) + let replaceConfiguration = DispatchSemaphore(value: 0) + let configurationChecked = DispatchSemaphore(value: 0) + let popProbe = CLIProxyAPICollectionContinuationProbe() + let lockHolder = Task.detached { + try await CostUsageCacheLocations.withCLIProxyAPIInterprocessLock(stateRoot: root) { + lockAcquired.signal() + _ = await Self.waitForSignal(replaceConfiguration, timeout: .distantFuture) + let replacement = try #require( + CostUsageCacheLocations.prepareCLIProxyAPIConfigurationGenerationUpdate(stateRoot: root)) + #expect(CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate(replacement)) + } + } + #expect(await Self.waitForSignal(lockAcquired, timeout: .now() + 1)) + let client = CLIProxyAPIUsageQueueClient( + settings: .init(managementKey: "management-secret"), + dataLoader: { request in + await popProbe.recordPop() + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + return (Data("[]".utf8), response) + }) + let collection = Task.detached { + await CLIProxyAPIUsageCollector.collect( + cacheRoot: cacheRoot, + settings: .init(managementKey: "management-secret"), + currentSettingsResult: { + configurationChecked.signal() + return .temporarilyUnavailable + }, + client: client) + } + #expect(await Self.waitForSignal(configurationChecked, timeout: .now() + 1)) + replaceConfiguration.signal() + + try await lockHolder.value + #expect(await collection.value == .notConfigured) + #expect(await popProbe.popCount == 0) + } + @Test func `collector rechecks configuration before every destructive pop`() async throws { let fileManager = FileManager.default diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index eca272e967..2f5ab667fd 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -6,6 +6,17 @@ import Testing @Suite(.serialized) struct CostUsageFetcherTests { + @Test + func `regular refresh options preserve the default proxy home`() { + let options = CostUsageFetcher().resolvedTokenSnapshotScannerOptions( + provider: .claude, + codexHomePath: nil, + calendar: nil) + + #expect(options.cliProxyAPIHome == FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".cli-proxy-api", isDirectory: true)) + } + @Test func `native codex sessions survive when pi usage is present but pi merge is disabled`() async throws { let env = try CostUsageTestEnvironment() diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 6fe648d9cf..81ee9d4d0b 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1347,37 +1347,37 @@ struct ProviderArchitectureGatekeeperTests { reason: "This provider-owned integration passes its fixed identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 329, + line: 344, 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: 341, + line: 356, 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: 357, + line: 372, 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: 880, + line: 895, 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: 961, + line: 976, 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: 1071, + line: 1086, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), @@ -3453,7 +3453,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: 667, + line: 682, anchor: "if provider == .codex {", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3461,7 +3461,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: 697, + line: 712, anchor: "provider == .claude || (provider == .codex && options.shouldMergeGlobalCodexUsage)", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 4, @@ -3469,7 +3469,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: 747, + line: 762, anchor: "options.provider == .codex || options.provider == .claude", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 2, @@ -3477,7 +3477,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: 778, + line: 793, anchor: "guard provider == .codex || provider == .claude else { return nil }", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 2, @@ -3485,7 +3485,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: 1629, + line: 1644, anchor: "if provider == .vertexai {", expectedProviderIDs: ["claude", "vertexai"], expectedReferenceCount: 2, @@ -3493,7 +3493,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: 2019, + line: 2034, anchor: "if provider == .cursor {", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, From fe98893d63fa82c26b475ea8d7fced3e61327e38 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sat, 22 Aug 2026 06:16:22 -0700 Subject: [PATCH 119/183] Preserve proxy home for cached calendars --- Sources/CodexBarCore/CostUsageFetcher.swift | 7 +++++-- .../CostUsageFetcherCachedProxyTimeZoneTests.swift | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 570377deb6..4924a7b29f 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -314,9 +314,12 @@ public struct CostUsageFetcher: Sendable { self.scannerOptions } - private func scannerOptions(calendar: Calendar?) -> CostUsageScanner.Options? { + func scannerOptions(calendar: Calendar?) -> CostUsageScanner.Options? { guard calendar != nil || self.scannerOptions != nil else { return self.scannerOptions } - var options = self.scannerOptions ?? CostUsageScanner.Options() + var options = Self.resolvedScannerOptions( + self.scannerOptions, + provider: .codex, + codexHomePath: nil) if let calendar { options.calendar = calendar } diff --git a/Tests/CodexBarTests/CostUsageFetcherCachedProxyTimeZoneTests.swift b/Tests/CodexBarTests/CostUsageFetcherCachedProxyTimeZoneTests.swift index e97890607d..599630a070 100644 --- a/Tests/CodexBarTests/CostUsageFetcherCachedProxyTimeZoneTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherCachedProxyTimeZoneTests.swift @@ -4,11 +4,11 @@ import Testing struct CostUsageFetcherCachedProxyTimeZoneTests { @Test - func `calendar scoped fetcher retains the default proxy home`() throws { + func `default fetcher cached calendar options retain the default proxy home`() throws { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = try #require(TimeZone(identifier: "Asia/Shanghai")) - let options = try #require(CostUsageFetcher(calendar: calendar).scannerOptionsOverride()) + let options = try #require(CostUsageFetcher().scannerOptions(calendar: calendar)) #expect(options.calendar.timeZone == calendar.timeZone) #expect(options.cliProxyAPIHome?.lastPathComponent == ".cli-proxy-api") From f57f48a1aae1d6d700f225baf4dadf5367031393 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sat, 22 Aug 2026 06:35:53 -0700 Subject: [PATCH 120/183] Harden proxy cache refresh races --- Sources/CodexBar/UsageStore+CLIProxyAPI.swift | 13 +++- .../UsageStore+TokenRefreshSequence.swift | 14 +++- .../CLIProxyAPIUsageTelemetry.swift | 22 ++++++ .../CLIProxyAPIUsageStoreTests.swift | 72 +++++++++++++++++++ 4 files changed, 117 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift index 9b293e1e64..1ce5a44997 100644 --- a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift +++ b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift @@ -10,6 +10,7 @@ struct CLIProxyAPIUsageCollectorState: Equatable { var configurationAvailability: ConfigurationAvailability = .unknown var configurationGeneration: String? + var telemetryRevision: String? } @MainActor @@ -39,7 +40,8 @@ extension UsageStore { self.cliProxyAPIUsageCollectorTask = Task.detached(priority: .utility) { [weak self] in var nextPendingPruneAt: ContinuousClock.Instant? var collectorState = CLIProxyAPIUsageCollectorState( - configurationGeneration: initialConfigurationGeneration) + configurationGeneration: initialConfigurationGeneration, + telemetryRevision: CLIProxyAPIUsageTelemetryRevision.current()) while !Task.isCancelled { let now = ContinuousClock.now if nextPendingPruneAt.map({ now >= $0 }) ?? true { @@ -143,6 +145,9 @@ extension UsageStore { configurationGeneration: () -> String? = { CostUsageCacheLocations.cliProxyAPIConfigurationGeneration() }, + telemetryRevision: () -> String? = { + CLIProxyAPIUsageTelemetryRevision.current() + }, refresh: ((UsageProvider, Bool) async -> Void)? = nil) async -> CLIProxyAPIUsageCollectorState { var collectorState = collectorState @@ -155,17 +160,21 @@ extension UsageStore { } collectorState.configurationAvailability = .unavailable collectorState.configurationGeneration = configurationGeneration() + collectorState.telemetryRevision = telemetryRevision() case let .collected(count): let currentGeneration = configurationGeneration() + let currentTelemetryRevision = telemetryRevision() if count > 0 || collectorState.configurationAvailability == .unavailable || (collectorState.configurationGeneration != nil && - collectorState.configurationGeneration != currentGeneration) + collectorState.configurationGeneration != currentGeneration) || + collectorState.telemetryRevision != currentTelemetryRevision { await self.refreshCLIProxyAPICostAttribution(refresh: refresh) } collectorState.configurationAvailability = .available collectorState.configurationGeneration = currentGeneration + collectorState.telemetryRevision = currentTelemetryRevision case .failed: let currentGeneration = configurationGeneration() if collectorState.configurationGeneration != nil, diff --git a/Sources/CodexBar/UsageStore+TokenRefreshSequence.swift b/Sources/CodexBar/UsageStore+TokenRefreshSequence.swift index 0cc42efe33..360853e187 100644 --- a/Sources/CodexBar/UsageStore+TokenRefreshSequence.swift +++ b/Sources/CodexBar/UsageStore+TokenRefreshSequence.swift @@ -42,6 +42,7 @@ extension UsageStore { /// less than `forcedTokenRefreshMinInterval` ago already delivered fresh cost data, so the /// request is dropped instead of queued. func scheduleForcedTokenRefresh(now: Date = Date()) { + guard !self.costUsageCacheClearInProgress else { return } if let last = self.lastForcedTokenRefreshStartedAt, now.timeIntervalSince(last) >= 0, now.timeIntervalSince(last) < Self.forcedTokenRefreshMinInterval @@ -59,6 +60,7 @@ extension UsageStore { } func refreshTokenUsageSequenceNow(force: Bool) async { + guard !self.costUsageCacheClearInProgress else { return } guard let task = await self.serializedTokenRefreshTask(force: force, scope: .all) else { return } await self.awaitTokenRefreshSequence(task) } @@ -84,6 +86,7 @@ extension UsageStore { } func drainTokenRefreshesForCostCacheClear() async { + self.pendingForcedTokenRefresh = false self.tokenRefreshRetryProviders.removeAll() let sequenceTask = self.tokenRefreshSequenceTask sequenceTask?.cancel() @@ -91,6 +94,7 @@ extension UsageStore { while !self.tokenRefreshInFlight.isEmpty { await Task.yield() } + self.pendingForcedTokenRefresh = false self.tokenRefreshRetryProviders.removeAll() } @@ -98,6 +102,7 @@ extension UsageStore { force: Bool, scope: TokenRefreshSequenceScope) async -> Task? { + guard !self.costUsageCacheClearInProgress else { return nil } if force { while let existing = self.tokenRefreshSequenceTask { existing.cancel() @@ -169,7 +174,10 @@ extension UsageStore { /// on start instead. @discardableResult private func startPendingForcedTokenRefreshIfPossible() -> Bool { - guard self.pendingForcedTokenRefresh, !Task.isCancelled else { return false } + guard !self.costUsageCacheClearInProgress, + self.pendingForcedTokenRefresh, + !Task.isCancelled + else { return false } self.pendingForcedTokenRefresh = false guard !self.hasForcedRefreshEnrichmentInFlight else { return false } // The forced all-provider pass rescans every enabled lane, so stale-retry lanes fold into it. @@ -179,6 +187,7 @@ extension UsageStore { } func requestTokenRefreshAfterStaleCompletion(for provider: UsageProvider) { + guard !self.costUsageCacheClearInProgress else { return } self.tokenRefreshRetryProviders.insert(provider.instanceID) Task { @MainActor [weak self] in await Task.yield() @@ -188,7 +197,8 @@ extension UsageStore { @discardableResult private func startPendingTokenRefreshRetryIfPossible() -> Bool { - guard !self.tokenRefreshRetryProviders.isEmpty, + guard !self.costUsageCacheClearInProgress, + !self.tokenRefreshRetryProviders.isEmpty, self.tokenRefreshSequenceTask == nil, self.settings.costUsageEnabled || self.settings.codexLocalSessionCostLedgerEnabled else { diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index f40b2a304e..dcf46294f6 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -358,6 +358,19 @@ enum CLIProxyAPIUsageCacheIO { .appendingPathComponent(CostUsageCacheLocations.cliProxyAPIUsageFileName, isDirectory: false) } + static func revision( + cacheRoot: URL? = nil, + fileManager: FileManager = .default) -> String? + { + let url = self.cacheFileURL(cacheRoot: cacheRoot) + guard let attributes = try? fileManager.attributesOfItem(atPath: url.path) else { return nil } + let fileNumber = (attributes[.systemFileNumber] as? NSNumber)?.uint64Value ?? 0 + let modificationDate = (attributes[.modificationDate] as? Date)? + .timeIntervalSinceReferenceDate.bitPattern ?? 0 + let fileSize = (attributes[.size] as? NSNumber)?.uint64Value ?? 0 + return "\(fileNumber):\(modificationDate):\(fileSize)" + } + static func legacyCacheFileURL(cacheRoot: URL? = nil) -> URL { let root = cacheRoot ?? self.defaultLegacyCacheRoot() return root @@ -518,6 +531,15 @@ enum CLIProxyAPIUsageCacheIO { } } +package enum CLIProxyAPIUsageTelemetryRevision { + package static func current( + cacheRoot: URL? = nil, + fileManager: FileManager = .default) -> String? + { + CLIProxyAPIUsageCacheIO.revision(cacheRoot: cacheRoot, fileManager: fileManager) + } +} + enum CLIProxyAPIUsagePendingIO { private struct PendingBatch: Codable { var version: Int = 1 diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift index 5cf9155b59..cf669a92b5 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift @@ -460,6 +460,41 @@ struct CLIProxyAPIUsageStoreTests { #expect(refreshes.map(\.1) == [true, true]) } + @Test + func `telemetry imported by another process refreshes proxy affected snapshots`() async { + let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") + settings.costUsageEnabled = true + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + store.publishTokenSnapshot(Self.tokenSnapshot(), for: .codex) + store.publishTokenSnapshot(Self.tokenSnapshot(), for: .claude) + var refreshes: [(UsageProvider, Bool)] = [] + + let collectorState = await store.handleCLIProxyAPIUsageCollectionResult( + .collected(0), + collectorState: CLIProxyAPIUsageCollectorState( + configurationAvailability: .available, + configurationGeneration: "current-generation", + telemetryRevision: "before-import"), + configurationGeneration: { "current-generation" }, + telemetryRevision: { "after-import" }, + refresh: { provider, force in + refreshes.append((provider, force)) + }) + + #expect(collectorState.configurationAvailability == .available) + #expect(collectorState.configurationGeneration == "current-generation") + #expect(collectorState.telemetryRevision == "after-import") + #expect(store.tokenSnapshot(for: .codex) == nil) + #expect(store.tokenSnapshot(for: .claude) == nil) + #expect(refreshes.map(\.0) == [.claude, .codex]) + #expect(refreshes.map(\.1) == [true, true]) + } + @Test func `failed generation transition stays pending until collection succeeds`() async { let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") @@ -651,6 +686,43 @@ struct CLIProxyAPIUsageStoreTests { #expect(store.tokenRefreshSequenceTask == nil) } + @Test + func `clearing cost cache drops a queued forced token scan`() async { + let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") + settings.costUsageEnabled = true + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + let refreshCount = LockIsolated(0) + store._test_tokenUsageRefreshOverride = { _, _ in + refreshCount.setValue(refreshCount.value + 1) + while !Task.isCancelled { + await Task.yield() + } + } + defer { store._test_tokenUsageRefreshOverride = nil } + + store.scheduleTokenRefreshForTesting() + while refreshCount.value == 0 { + await Task.yield() + } + store.scheduleForcedTokenRefresh() + #expect(store.pendingForcedTokenRefresh) + + let error = await store.clearCostUsageCache(clearDirectories: { + (cleared: 0, errorMessage: nil) + }) + try? await Task.sleep(for: .milliseconds(50)) + + #expect(error == nil) + #expect(refreshCount.value == 1) + #expect(!store.pendingForcedTokenRefresh) + #expect(store.tokenRefreshSequenceTask == nil) + } + @Test func `clearing cost cache uses the shared locked deletion path`() async throws { let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") From 0955135d8afe4eb89929a612488f49c875641787 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sat, 22 Aug 2026 06:50:23 -0700 Subject: [PATCH 121/183] Refresh imported telemetry after failures --- Sources/CodexBar/UsageStore+CLIProxyAPI.swift | 12 +++++-- .../CLIProxyAPIUsageStoreTests.swift | 35 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift index 1ce5a44997..f6df0df3ad 100644 --- a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift +++ b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift @@ -177,13 +177,19 @@ extension UsageStore { collectorState.telemetryRevision = currentTelemetryRevision case .failed: let currentGeneration = configurationGeneration() - if collectorState.configurationGeneration != nil, - collectorState.configurationGeneration != currentGeneration - { + let currentTelemetryRevision = telemetryRevision() + let configurationChanged = collectorState.configurationGeneration != nil && + collectorState.configurationGeneration != currentGeneration + if collectorState.telemetryRevision != currentTelemetryRevision { + await self.refreshCLIProxyAPICostAttribution(refresh: refresh) + } else if configurationChanged { self.invalidateCLIProxyAPICostAttribution(widgetReason: "cliproxyapi-configuration-changed") + } + if configurationChanged { collectorState.configurationAvailability = .unavailable collectorState.configurationGeneration = currentGeneration } + collectorState.telemetryRevision = currentTelemetryRevision case .disabled: break } diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift index cf669a92b5..9ccd9e2ad7 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift @@ -495,6 +495,41 @@ struct CLIProxyAPIUsageStoreTests { #expect(refreshes.map(\.1) == [true, true]) } + @Test + func `failed collection still refreshes telemetry imported by another process`() async { + let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") + settings.costUsageEnabled = true + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + store.publishTokenSnapshot(Self.tokenSnapshot(), for: .codex) + store.publishTokenSnapshot(Self.tokenSnapshot(), for: .claude) + var refreshes: [(UsageProvider, Bool)] = [] + + let collectorState = await store.handleCLIProxyAPIUsageCollectionResult( + .failed("proxy unavailable"), + collectorState: CLIProxyAPIUsageCollectorState( + configurationAvailability: .available, + configurationGeneration: "current-generation", + telemetryRevision: "before-import"), + configurationGeneration: { "current-generation" }, + telemetryRevision: { "after-import" }, + refresh: { provider, force in + refreshes.append((provider, force)) + }) + + #expect(collectorState.configurationAvailability == .available) + #expect(collectorState.configurationGeneration == "current-generation") + #expect(collectorState.telemetryRevision == "after-import") + #expect(store.tokenSnapshot(for: .codex) == nil) + #expect(store.tokenSnapshot(for: .claude) == nil) + #expect(refreshes.map(\.0) == [.claude, .codex]) + #expect(refreshes.map(\.1) == [true, true]) + } + @Test func `failed generation transition stays pending until collection succeeds`() async { let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") From eaa9a69f1cd2ff88850633a6e6a71b4bdd34e083 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sat, 22 Aug 2026 18:18:21 -0700 Subject: [PATCH 122/183] Refresh native costs after proxy disconnects --- Sources/CodexBar/UsageStore+CLIProxyAPI.swift | 1 + .../CLIProxyAPIUsageStoreTests.swift | 26 ++++++++++++---- .../ProviderArchitectureGatekeeperTests.swift | 30 +++++++++++-------- 3 files changed, 40 insertions(+), 17 deletions(-) diff --git a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift index f6df0df3ad..f0212641f1 100644 --- a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift +++ b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift @@ -157,6 +157,7 @@ extension UsageStore { (collectorState.configurationAvailability == .unknown && isExplicitlyDisconnected()) { self.invalidateCLIProxyAPICostAttribution(widgetReason: "cliproxyapi-disconnected") + await self.refreshCLIProxyAPIAffectedProviders(refresh: refresh) } collectorState.configurationAvailability = .unavailable collectorState.configurationGeneration = configurationGeneration() diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift index 9ccd9e2ad7..43255a6901 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift @@ -313,7 +313,7 @@ struct CLIProxyAPIUsageStoreTests { } @Test - func `background disconnect invalidates once and remote reconnect refreshes snapshots`() async { + func `background disconnect refreshes native snapshots once and remote reconnect refreshes snapshots`() async { let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") settings.costUsageEnabled = true let root = FileManager.default.temporaryDirectory @@ -335,11 +335,15 @@ struct CLIProxyAPIUsageStoreTests { let claudePublicationRevision = store.tokenSnapshotPublicationRevision(for: .claude) let dashboardRevision = store.spendDashboardCodexCostCatchUpRevision let dashboardConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) + var refreshes: [(UsageProvider, Bool)] = [] var collectorState = await store.handleCLIProxyAPIUsageCollectionResult( .notConfigured, collectorState: CLIProxyAPIUsageCollectorState(configurationAvailability: .available), - isExplicitlyDisconnected: { false }) + isExplicitlyDisconnected: { false }, + refresh: { provider, force in + refreshes.append((provider, force)) + }) #expect(collectorState.configurationAvailability == .unavailable) #expect(store.tokenSnapshot(for: .codex) == nil) @@ -347,6 +351,8 @@ struct CLIProxyAPIUsageStoreTests { #expect(store.tokenSnapshotPublicationRevision(for: .codex) == codexPublicationRevision + 1) #expect(store.tokenSnapshotPublicationRevision(for: .claude) == claudePublicationRevision + 1) #expect(store.spendDashboardCodexCostCatchUpRevision == dashboardRevision + 1) + #expect(refreshes.map(\.0) == [.claude, .codex]) + #expect(refreshes.map(\.1) == [true, true]) #expect( SpendDashboardSource.configuration(settings: settings, store: store).sourceRevisions != dashboardConfiguration.sourceRevisions) @@ -354,13 +360,17 @@ struct CLIProxyAPIUsageStoreTests { collectorState = await store.handleCLIProxyAPIUsageCollectionResult( .notConfigured, collectorState: collectorState, - isExplicitlyDisconnected: { false }) + isExplicitlyDisconnected: { false }, + refresh: { provider, force in + refreshes.append((provider, force)) + }) #expect(store.tokenSnapshotPublicationRevision(for: .codex) == codexPublicationRevision + 1) #expect(store.tokenSnapshotPublicationRevision(for: .claude) == claudePublicationRevision + 1) #expect(store.spendDashboardCodexCostCatchUpRevision == dashboardRevision + 1) + #expect(refreshes.map(\.0) == [.claude, .codex]) + refreshes.removeAll() - var refreshes: [(UsageProvider, Bool)] = [] collectorState = await store.handleCLIProxyAPIUsageCollectionResult( .failed("temporary failure"), collectorState: collectorState, @@ -377,14 +387,20 @@ struct CLIProxyAPIUsageStoreTests { #expect(refreshes.map(\.0) == [.claude, .codex]) #expect(refreshes.map(\.1) == [true, true]) store.publishTokenSnapshot(Self.tokenSnapshot(), for: .codex) + refreshes.removeAll() collectorState = await store.handleCLIProxyAPIUsageCollectionResult( .notConfigured, collectorState: collectorState, - isExplicitlyDisconnected: { false }) + isExplicitlyDisconnected: { false }, + refresh: { provider, force in + refreshes.append((provider, force)) + }) #expect(collectorState.configurationAvailability == .unavailable) #expect(store.tokenSnapshot(for: .codex) == nil) #expect(store.tokenSnapshotPublicationRevision(for: .codex) == codexPublicationRevision + 4) + #expect(refreshes.map(\.0) == [.claude, .codex]) + #expect(refreshes.map(\.1) == [true, true]) } @Test diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 81ee9d4d0b..12c776961f 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1347,37 +1347,43 @@ struct ProviderArchitectureGatekeeperTests { reason: "This provider-owned integration passes its fixed identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 344, + line: 321, + 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: 347, 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: 356, + line: 359, 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: 372, + line: 375, 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: 895, + line: 898, 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: 976, + line: 979, 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: 1086, + line: 1089, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), @@ -3453,7 +3459,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: 682, + line: 685, anchor: "if provider == .codex {", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3461,7 +3467,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: 712, + line: 715, anchor: "provider == .claude || (provider == .codex && options.shouldMergeGlobalCodexUsage)", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 4, @@ -3469,7 +3475,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: 762, + line: 765, anchor: "options.provider == .codex || options.provider == .claude", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 2, @@ -3477,7 +3483,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: 793, + line: 796, anchor: "guard provider == .codex || provider == .claude else { return nil }", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 2, @@ -3485,7 +3491,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: 1644, + line: 1647, anchor: "if provider == .vertexai {", expectedProviderIDs: ["claude", "vertexai"], expectedReferenceCount: 2, @@ -3493,7 +3499,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: 2034, + line: 2037, anchor: "if provider == .cursor {", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, From c471a6e2b969e1bec95d5fe05f750a81090baa46 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sat, 22 Aug 2026 18:29:59 -0700 Subject: [PATCH 123/183] Isolate proxy attribution before disconnect refresh --- Sources/CodexBar/UsageStore+CLIProxyAPI.swift | 8 +++ .../CostUsageCacheLocations.swift | 2 +- .../CLIProxyAPIUsageStoreTests.swift | 58 ++++++++++++++++++- 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift index f0212641f1..0d34c1acb5 100644 --- a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift +++ b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift @@ -142,6 +142,9 @@ extension UsageStore { isExplicitlyDisconnected: () -> Bool = { CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected() }, + publishAttributionIsolation: () -> Bool = { + CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected(true) + }, configurationGeneration: () -> String? = { CostUsageCacheLocations.cliProxyAPIConfigurationGeneration() }, @@ -156,6 +159,11 @@ extension UsageStore { if collectorState.configurationAvailability == .available || (collectorState.configurationAvailability == .unknown && isExplicitlyDisconnected()) { + guard publishAttributionIsolation() else { + collectorState.configurationGeneration = configurationGeneration() + collectorState.telemetryRevision = telemetryRevision() + return collectorState + } self.invalidateCLIProxyAPICostAttribution(widgetReason: "cliproxyapi-disconnected") await self.refreshCLIProxyAPIAffectedProviders(refresh: refresh) } diff --git a/Sources/CodexBarCore/CostUsageCacheLocations.swift b/Sources/CodexBarCore/CostUsageCacheLocations.swift index c29f773873..71b71baea3 100644 --- a/Sources/CodexBarCore/CostUsageCacheLocations.swift +++ b/Sources/CodexBarCore/CostUsageCacheLocations.swift @@ -648,7 +648,7 @@ public enum CostUsageCacheLocations { } @discardableResult - static func setCLIProxyAPIExplicitlyDisconnected( + package static func setCLIProxyAPIExplicitlyDisconnected( _ disconnected: Bool, stateRoot: URL? = nil, fileManager: FileManager = .default) -> Bool diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift index 43255a6901..c49d1139dc 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift @@ -303,7 +303,11 @@ struct CLIProxyAPIUsageStoreTests { let collectorState = await store.handleCLIProxyAPIUsageCollectionResult( .notConfigured, collectorState: CLIProxyAPIUsageCollectorState(), - isExplicitlyDisconnected: { false }) + isExplicitlyDisconnected: { false }, + publishAttributionIsolation: { + Issue.record("Initial missing configuration must not publish disconnect isolation.") + return false + }) #expect(collectorState.configurationAvailability == .unavailable) #expect(store.tokenSnapshot(for: .codex) == snapshot) @@ -335,13 +339,21 @@ struct CLIProxyAPIUsageStoreTests { let claudePublicationRevision = store.tokenSnapshotPublicationRevision(for: .claude) let dashboardRevision = store.spendDashboardCodexCostCatchUpRevision let dashboardConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) + var isolationPublicationCount = 0 + var attributionIsolated = false var refreshes: [(UsageProvider, Bool)] = [] var collectorState = await store.handleCLIProxyAPIUsageCollectionResult( .notConfigured, collectorState: CLIProxyAPIUsageCollectorState(configurationAvailability: .available), isExplicitlyDisconnected: { false }, + publishAttributionIsolation: { + isolationPublicationCount += 1 + attributionIsolated = true + return true + }, refresh: { provider, force in + #expect(attributionIsolated) refreshes.append((provider, force)) }) @@ -351,6 +363,7 @@ struct CLIProxyAPIUsageStoreTests { #expect(store.tokenSnapshotPublicationRevision(for: .codex) == codexPublicationRevision + 1) #expect(store.tokenSnapshotPublicationRevision(for: .claude) == claudePublicationRevision + 1) #expect(store.spendDashboardCodexCostCatchUpRevision == dashboardRevision + 1) + #expect(isolationPublicationCount == 1) #expect(refreshes.map(\.0) == [.claude, .codex]) #expect(refreshes.map(\.1) == [true, true]) #expect( @@ -361,6 +374,10 @@ struct CLIProxyAPIUsageStoreTests { .notConfigured, collectorState: collectorState, isExplicitlyDisconnected: { false }, + publishAttributionIsolation: { + isolationPublicationCount += 1 + return true + }, refresh: { provider, force in refreshes.append((provider, force)) }) @@ -368,6 +385,7 @@ struct CLIProxyAPIUsageStoreTests { #expect(store.tokenSnapshotPublicationRevision(for: .codex) == codexPublicationRevision + 1) #expect(store.tokenSnapshotPublicationRevision(for: .claude) == claudePublicationRevision + 1) #expect(store.spendDashboardCodexCostCatchUpRevision == dashboardRevision + 1) + #expect(isolationPublicationCount == 1) #expect(refreshes.map(\.0) == [.claude, .codex]) refreshes.removeAll() @@ -387,22 +405,60 @@ struct CLIProxyAPIUsageStoreTests { #expect(refreshes.map(\.0) == [.claude, .codex]) #expect(refreshes.map(\.1) == [true, true]) store.publishTokenSnapshot(Self.tokenSnapshot(), for: .codex) + attributionIsolated = false refreshes.removeAll() collectorState = await store.handleCLIProxyAPIUsageCollectionResult( .notConfigured, collectorState: collectorState, isExplicitlyDisconnected: { false }, + publishAttributionIsolation: { + isolationPublicationCount += 1 + attributionIsolated = true + return true + }, refresh: { provider, force in + #expect(attributionIsolated) refreshes.append((provider, force)) }) #expect(collectorState.configurationAvailability == .unavailable) #expect(store.tokenSnapshot(for: .codex) == nil) #expect(store.tokenSnapshotPublicationRevision(for: .codex) == codexPublicationRevision + 4) + #expect(isolationPublicationCount == 2) #expect(refreshes.map(\.0) == [.claude, .codex]) #expect(refreshes.map(\.1) == [true, true]) } + @Test + func `disconnect isolation failure preserves snapshots and retries on the next poll`() async { + let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") + settings.costUsageEnabled = true + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + let snapshot = Self.tokenSnapshot() + store.publishTokenSnapshot(snapshot, for: .codex) + store.publishTokenSnapshot(snapshot, for: .claude) + var refreshes: [(UsageProvider, Bool)] = [] + + let collectorState = await store.handleCLIProxyAPIUsageCollectionResult( + .notConfigured, + collectorState: CLIProxyAPIUsageCollectorState(configurationAvailability: .available), + isExplicitlyDisconnected: { false }, + publishAttributionIsolation: { false }, + refresh: { provider, force in + refreshes.append((provider, force)) + }) + + #expect(collectorState.configurationAvailability == .available) + #expect(store.tokenSnapshot(for: .codex) == snapshot) + #expect(store.tokenSnapshot(for: .claude) == snapshot) + #expect(refreshes.isEmpty) + } + @Test func `configuration generation detects a reconnect missed between polls`() async { let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") From 22e9b30256113eaddc9badb8fc3151ed6a94557f Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sat, 22 Aug 2026 18:42:55 -0700 Subject: [PATCH 124/183] Keep proxy configuration transitions pending --- Sources/CodexBar/UsageStore+CLIProxyAPI.swift | 32 +++++++++-- .../CLIProxyAPIUsageStoreTests.swift | 11 ++-- ...CLIProxyAPIUsageStoreTransitionTests.swift | 56 +++++++++++++++++++ 3 files changed, 90 insertions(+), 9 deletions(-) create mode 100644 Tests/CodexBarTests/CLIProxyAPIUsageStoreTransitionTests.swift diff --git a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift index 0d34c1acb5..ef10a2e619 100644 --- a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift +++ b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift @@ -11,6 +11,7 @@ struct CLIProxyAPIUsageCollectorState: Equatable { var configurationAvailability: ConfigurationAvailability = .unknown var configurationGeneration: String? var telemetryRevision: String? + var configurationTransitionPending = false } @MainActor @@ -156,25 +157,44 @@ extension UsageStore { var collectorState = collectorState switch result { case .notConfigured: + let explicitlyDisconnected = isExplicitlyDisconnected() + let currentGeneration = configurationGeneration() + let configurationChanged = collectorState.configurationGeneration != nil && + collectorState.configurationGeneration != currentGeneration + if configurationChanged, !explicitlyDisconnected { + if !collectorState.configurationTransitionPending { + self.invalidateCLIProxyAPICostAttribution(widgetReason: "cliproxyapi-configuration-changed") + } + collectorState.configurationAvailability = .unavailable + collectorState.configurationGeneration = currentGeneration + collectorState.telemetryRevision = telemetryRevision() + collectorState.configurationTransitionPending = true + return collectorState + } if collectorState.configurationAvailability == .available || - (collectorState.configurationAvailability == .unknown && isExplicitlyDisconnected()) + (collectorState.configurationAvailability == .unknown && explicitlyDisconnected) || + collectorState.configurationTransitionPending { guard publishAttributionIsolation() else { - collectorState.configurationGeneration = configurationGeneration() + collectorState.configurationGeneration = currentGeneration collectorState.telemetryRevision = telemetryRevision() return collectorState } - self.invalidateCLIProxyAPICostAttribution(widgetReason: "cliproxyapi-disconnected") + if !collectorState.configurationTransitionPending { + self.invalidateCLIProxyAPICostAttribution(widgetReason: "cliproxyapi-disconnected") + } await self.refreshCLIProxyAPIAffectedProviders(refresh: refresh) } collectorState.configurationAvailability = .unavailable - collectorState.configurationGeneration = configurationGeneration() + collectorState.configurationGeneration = currentGeneration collectorState.telemetryRevision = telemetryRevision() + collectorState.configurationTransitionPending = false case let .collected(count): let currentGeneration = configurationGeneration() let currentTelemetryRevision = telemetryRevision() if count > 0 || collectorState.configurationAvailability == .unavailable || + collectorState.configurationTransitionPending || (collectorState.configurationGeneration != nil && collectorState.configurationGeneration != currentGeneration) || collectorState.telemetryRevision != currentTelemetryRevision @@ -184,6 +204,7 @@ extension UsageStore { collectorState.configurationAvailability = .available collectorState.configurationGeneration = currentGeneration collectorState.telemetryRevision = currentTelemetryRevision + collectorState.configurationTransitionPending = false case .failed: let currentGeneration = configurationGeneration() let currentTelemetryRevision = telemetryRevision() @@ -191,12 +212,13 @@ extension UsageStore { collectorState.configurationGeneration != currentGeneration if collectorState.telemetryRevision != currentTelemetryRevision { await self.refreshCLIProxyAPICostAttribution(refresh: refresh) - } else if configurationChanged { + } else if configurationChanged, !collectorState.configurationTransitionPending { self.invalidateCLIProxyAPICostAttribution(widgetReason: "cliproxyapi-configuration-changed") } if configurationChanged { collectorState.configurationAvailability = .unavailable collectorState.configurationGeneration = currentGeneration + collectorState.configurationTransitionPending = true } collectorState.telemetryRevision = currentTelemetryRevision case .disabled: diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift index c49d1139dc..cbb0eb7de2 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift @@ -603,7 +603,7 @@ struct CLIProxyAPIUsageStoreTests { } @Test - func `failed generation transition stays pending until collection succeeds`() async { + func `failed generation transition stays pending through a later disconnect`() async { let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") settings.costUsageEnabled = true let store = UsageStore( @@ -623,16 +623,19 @@ struct CLIProxyAPIUsageStoreTests { #expect(collectorState.configurationAvailability == .unavailable) #expect(collectorState.configurationGeneration == "new-generation") + #expect(collectorState.configurationTransitionPending) collectorState = await store.handleCLIProxyAPIUsageCollectionResult( - .collected(0), + .notConfigured, collectorState: collectorState, + isExplicitlyDisconnected: { true }, configurationGeneration: { "new-generation" }, refresh: { provider, force in refreshes.append((provider, force)) }) - #expect(collectorState.configurationAvailability == .available) + #expect(collectorState.configurationAvailability == .unavailable) + #expect(!collectorState.configurationTransitionPending) #expect(refreshes.map(\.0) == [.claude, .codex]) #expect(refreshes.map(\.1) == [true, true]) } @@ -877,7 +880,7 @@ struct CLIProxyAPIUsageStoreTests { #expect(store.tokenSnapshot(for: .codex) == nil) } - private static func tokenSnapshot() -> CostUsageTokenSnapshot { + static func tokenSnapshot() -> CostUsageTokenSnapshot { CostUsageTokenSnapshot( sessionTokens: 10, sessionCostUSD: 0.01, diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTransitionTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTransitionTests.swift new file mode 100644 index 0000000000..858c004fdc --- /dev/null +++ b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTransitionTests.swift @@ -0,0 +1,56 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension CLIProxyAPIUsageStoreTests { + @Test + func `stale collector replacement stays pending without publishing disconnect isolation`() async { + let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") + settings.costUsageEnabled = true + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + store.publishTokenSnapshot(Self.tokenSnapshot(), for: .codex) + store.publishTokenSnapshot(Self.tokenSnapshot(), for: .claude) + var refreshes: [(UsageProvider, Bool)] = [] + + var collectorState = await store.handleCLIProxyAPIUsageCollectionResult( + .notConfigured, + collectorState: CLIProxyAPIUsageCollectorState( + configurationAvailability: .available, + configurationGeneration: "old-generation"), + isExplicitlyDisconnected: { false }, + publishAttributionIsolation: { + Issue.record("A superseded collector must not disconnect the replacement configuration.") + return false + }, + configurationGeneration: { "replacement-generation" }, + refresh: { provider, force in + refreshes.append((provider, force)) + }) + + #expect(collectorState.configurationAvailability == .unavailable) + #expect(collectorState.configurationGeneration == "replacement-generation") + #expect(collectorState.configurationTransitionPending) + #expect(store.tokenSnapshot(for: .codex) == nil) + #expect(store.tokenSnapshot(for: .claude) == nil) + #expect(refreshes.isEmpty) + + collectorState = await store.handleCLIProxyAPIUsageCollectionResult( + .collected(0), + collectorState: collectorState, + configurationGeneration: { "replacement-generation" }, + refresh: { provider, force in + refreshes.append((provider, force)) + }) + + #expect(collectorState.configurationAvailability == .available) + #expect(!collectorState.configurationTransitionPending) + #expect(refreshes.map(\.0) == [.claude, .codex]) + #expect(refreshes.map(\.1) == [true, true]) + } +} From 3eaee5edab9889afd2ca943e69f8534b369aa00b Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sun, 23 Aug 2026 06:23:45 -0700 Subject: [PATCH 125/183] Serialize proxy disconnect isolation --- Sources/CodexBar/UsageStore+CLIProxyAPI.swift | 7 ++- .../CostUsageCacheLocations.swift | 25 ++++++++ .../CLIProxyAPIUsageCacheTests.swift | 57 +++++++++++++++++++ .../CLIProxyAPIUsageStoreTests.swift | 14 +++-- ...CLIProxyAPIUsageStoreTransitionTests.swift | 2 +- 5 files changed, 96 insertions(+), 9 deletions(-) diff --git a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift index ef10a2e619..889e299fbc 100644 --- a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift +++ b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift @@ -143,8 +143,9 @@ extension UsageStore { isExplicitlyDisconnected: () -> Bool = { CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected() }, - publishAttributionIsolation: () -> Bool = { - CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected(true) + publishAttributionIsolation: (String?) -> Bool = { expectedGeneration in + CostUsageCacheLocations.publishCLIProxyAPIAttributionIsolation( + expectedGeneration: expectedGeneration) }, configurationGeneration: () -> String? = { CostUsageCacheLocations.cliProxyAPIConfigurationGeneration() @@ -175,7 +176,7 @@ extension UsageStore { (collectorState.configurationAvailability == .unknown && explicitlyDisconnected) || collectorState.configurationTransitionPending { - guard publishAttributionIsolation() else { + guard publishAttributionIsolation(currentGeneration) else { collectorState.configurationGeneration = currentGeneration collectorState.telemetryRevision = telemetryRevision() return collectorState diff --git a/Sources/CodexBarCore/CostUsageCacheLocations.swift b/Sources/CodexBarCore/CostUsageCacheLocations.swift index 71b71baea3..54e521399d 100644 --- a/Sources/CodexBarCore/CostUsageCacheLocations.swift +++ b/Sources/CodexBarCore/CostUsageCacheLocations.swift @@ -678,6 +678,31 @@ public enum CostUsageCacheLocations { } } + @discardableResult + package static func publishCLIProxyAPIAttributionIsolation( + expectedGeneration: String?, + stateRoot: URL? = nil, + fileManager: FileManager = .default) -> Bool + { + do { + return try self.withCLIProxyAPIInterprocessLock( + stateRoot: stateRoot, + fileManager: fileManager) + { + guard self.cliProxyAPIConfigurationGeneration( + stateRoot: stateRoot, + fileManager: fileManager) == expectedGeneration + else { return false } + return self.setCLIProxyAPIExplicitlyDisconnected( + true, + stateRoot: stateRoot, + fileManager: fileManager) + } + } catch { + return false + } + } + private static func cliProxyAPIDisconnectedURL( stateRoot: URL?, fileManager: FileManager) -> URL diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index 83220f44de..3fc2a5762f 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -883,6 +883,63 @@ struct CLIProxyAPIUsageCacheTests { } extension CLIProxyAPIUsageCacheTests { + @Test + func `disconnect isolation does not mask a concurrent configuration save`() async throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-save-disconnect-race-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: root) } + let initialGeneration = try #require(CostUsageCacheLocations + .prepareCLIProxyAPIConfigurationGenerationUpdate(stateRoot: root, fileManager: fileManager)) + #expect(CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate( + initialGeneration, + fileManager: fileManager)) + let settings = CLIProxyAPIConnectionSettings(managementKey: "test-management-key") + let saveGenerationCommitted = DispatchSemaphore(value: 0) + let isolationStarted = DispatchSemaphore(value: 0) + + let saveTask = Task.detached { + CLIProxyAPIConnectionSettingsStore.saveSerialized( + settings, + stateRoot: root, + fileManager: .default, + operations: .init( + isDisconnected: { false }, + loadStored: { .found(settings) }, + store: { _ in + saveGenerationCommitted.signal() + isolationStarted.wait() + return true + }, + setDisconnectedState: { + CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected( + $0, + stateRoot: root, + fileManager: .default) + }, + restore: { _ in true })) + } + #expect(await Self.waitForSignal(saveGenerationCommitted, timeout: .now() + 1)) + + let isolationTask = Task.detached { + isolationStarted.signal() + return CostUsageCacheLocations.publishCLIProxyAPIAttributionIsolation( + expectedGeneration: initialGeneration.generation, + stateRoot: root, + fileManager: .default) + } + + #expect(await saveTask.value) + let isolationPublished = await isolationTask.value + #expect(!isolationPublished) + #expect(CostUsageCacheLocations.cliProxyAPIConfigurationGeneration( + stateRoot: root, + fileManager: fileManager) != initialGeneration.generation) + #expect(!CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( + stateRoot: root, + fileManager: fileManager)) + } + @Test func `usage retention clamps clock skew and rejects implausible future records`() throws { let fileManager = FileManager.default diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift index cbb0eb7de2..774564e3bf 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift @@ -304,7 +304,7 @@ struct CLIProxyAPIUsageStoreTests { .notConfigured, collectorState: CLIProxyAPIUsageCollectorState(), isExplicitlyDisconnected: { false }, - publishAttributionIsolation: { + publishAttributionIsolation: { _ in Issue.record("Initial missing configuration must not publish disconnect isolation.") return false }) @@ -347,7 +347,7 @@ struct CLIProxyAPIUsageStoreTests { .notConfigured, collectorState: CLIProxyAPIUsageCollectorState(configurationAvailability: .available), isExplicitlyDisconnected: { false }, - publishAttributionIsolation: { + publishAttributionIsolation: { _ in isolationPublicationCount += 1 attributionIsolated = true return true @@ -374,7 +374,7 @@ struct CLIProxyAPIUsageStoreTests { .notConfigured, collectorState: collectorState, isExplicitlyDisconnected: { false }, - publishAttributionIsolation: { + publishAttributionIsolation: { _ in isolationPublicationCount += 1 return true }, @@ -411,7 +411,7 @@ struct CLIProxyAPIUsageStoreTests { .notConfigured, collectorState: collectorState, isExplicitlyDisconnected: { false }, - publishAttributionIsolation: { + publishAttributionIsolation: { _ in isolationPublicationCount += 1 attributionIsolated = true return true @@ -448,7 +448,7 @@ struct CLIProxyAPIUsageStoreTests { .notConfigured, collectorState: CLIProxyAPIUsageCollectorState(configurationAvailability: .available), isExplicitlyDisconnected: { false }, - publishAttributionIsolation: { false }, + publishAttributionIsolation: { _ in false }, refresh: { provider, force in refreshes.append((provider, force)) }) @@ -629,6 +629,10 @@ struct CLIProxyAPIUsageStoreTests { .notConfigured, collectorState: collectorState, isExplicitlyDisconnected: { true }, + publishAttributionIsolation: { expectedGeneration in + #expect(expectedGeneration == "new-generation") + return true + }, configurationGeneration: { "new-generation" }, refresh: { provider, force in refreshes.append((provider, force)) diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTransitionTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTransitionTests.swift index 858c004fdc..590af77b9e 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTransitionTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTransitionTests.swift @@ -24,7 +24,7 @@ extension CLIProxyAPIUsageStoreTests { configurationAvailability: .available, configurationGeneration: "old-generation"), isExplicitlyDisconnected: { false }, - publishAttributionIsolation: { + publishAttributionIsolation: { _ in Issue.record("A superseded collector must not disconnect the replacement configuration.") return false }, From c8738c4d2110afc97244e4711f14ef823133e7e6 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sun, 23 Aug 2026 06:44:16 -0700 Subject: [PATCH 126/183] Guard proxy disconnect boundaries --- Sources/CodexBar/UsageStore+CLIProxyAPI.swift | 6 +-- .../CostUsageCacheLocations.swift | 12 +++++ .../CostUsage/CostUsageClaudeCache.swift | 1 + .../CostUsage/CostUsageScanner+Claude.swift | 47 ++++--------------- ...er+ClaudeCLIProxyAPIAttributionState.swift | 42 +++++++++++++++++ .../CLIProxyAPIUsageCacheTests.swift | 47 +++++++++++++++++++ .../CostUsageScannerClaudeMemoTests.swift | 36 ++++++++++++++ 7 files changed, 149 insertions(+), 42 deletions(-) create mode 100644 Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+ClaudeCLIProxyAPIAttributionState.swift diff --git a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift index 889e299fbc..cd977a181e 100644 --- a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift +++ b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift @@ -143,8 +143,8 @@ extension UsageStore { isExplicitlyDisconnected: () -> Bool = { CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected() }, - publishAttributionIsolation: (String?) -> Bool = { expectedGeneration in - CostUsageCacheLocations.publishCLIProxyAPIAttributionIsolation( + publishAttributionIsolation: (String?) async -> Bool = { expectedGeneration in + await CostUsageCacheLocations.publishCLIProxyAPIAttributionIsolationAsync( expectedGeneration: expectedGeneration) }, configurationGeneration: () -> String? = { @@ -176,7 +176,7 @@ extension UsageStore { (collectorState.configurationAvailability == .unknown && explicitlyDisconnected) || collectorState.configurationTransitionPending { - guard publishAttributionIsolation(currentGeneration) else { + guard await publishAttributionIsolation(currentGeneration) else { collectorState.configurationGeneration = currentGeneration collectorState.telemetryRevision = telemetryRevision() return collectorState diff --git a/Sources/CodexBarCore/CostUsageCacheLocations.swift b/Sources/CodexBarCore/CostUsageCacheLocations.swift index 54e521399d..ad0ff5a6b2 100644 --- a/Sources/CodexBarCore/CostUsageCacheLocations.swift +++ b/Sources/CodexBarCore/CostUsageCacheLocations.swift @@ -703,6 +703,18 @@ public enum CostUsageCacheLocations { } } + @discardableResult + package static func publishCLIProxyAPIAttributionIsolationAsync( + expectedGeneration: String?, + stateRoot: URL? = nil) async -> Bool + { + await Task.detached(priority: .utility) { + self.publishCLIProxyAPIAttributionIsolation( + expectedGeneration: expectedGeneration, + stateRoot: stateRoot) + }.value + } + private static func cliProxyAPIDisconnectedURL( stateRoot: URL?, fileManager: FileManager) -> URL diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift index e4c7f11f3f..5c7a7fb325 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift @@ -34,6 +34,7 @@ struct CostUsageClaudeReportMemoKey: Equatable, Sendable { let providerFilter: String let attributionFilter: String let cliProxyAPIConfigurationGeneration: String? + let cliProxyAPIAttributionEnabled: Bool let sinceKey: String let untilKey: String let scanSinceKey: String diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 50c0afa795..00d32b04fd 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -985,45 +985,6 @@ extension CostUsageScanner { return inventory } - private struct ClaudeCLIProxyAPIAttributionState { - let configurationGeneration: String? - let resolver: CLIProxyAPIAttributionResolver? - let usageArtifactStamp: CostUsageClaudeFileStamp? - } - - private static func captureClaudeCLIProxyAPIAttributionState( - options: Options, - checkCancellation: CancellationCheck?) throws -> ClaudeCLIProxyAPIAttributionState - { - try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( - stateRoot: options.cacheRoot) - { - let configurationGeneration = CostUsageCacheLocations.cliProxyAPIConfigurationGeneration( - stateRoot: options.cacheRoot) - let attributionResolver: CLIProxyAPIAttributionResolver? - if !CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected(stateRoot: options.cacheRoot), - let home = options.cliProxyAPIHome - { - let usageRecords = CLIProxyAPIUsageCacheIO.loadAssumingInterprocessLockHeld( - cacheRoot: options.cacheRoot) - attributionResolver = try CLIProxyAPIAttributionResolver.load( - home: home, - cacheRoot: options.cacheRoot, - forceReload: options.forceRescan, - usageRecords: usageRecords, - checkCancellation: checkCancellation) - } else { - attributionResolver = nil - } - let usageArtifactStamp = CostUsageClaudeFileStamp.read( - at: CLIProxyAPIUsageCacheIO.cacheFileURL(cacheRoot: options.cacheRoot)) - return ClaudeCLIProxyAPIAttributionState( - configurationGeneration: configurationGeneration, - resolver: attributionResolver, - usageArtifactStamp: usageArtifactStamp) - } - } - // swiftlint:disable:next function_body_length static func loadClaudeDaily( provider: UsageProvider, @@ -1037,6 +998,7 @@ extension CostUsageScanner { checkCancellation: checkCancellation) self.claudeCLIProxyAPIAttributionCaptureObserverStore?.observer() let cliProxyAPIConfigurationGeneration = cliProxyAPIAttributionState.configurationGeneration + let cliProxyAPIAttributionEnabled = cliProxyAPIAttributionState.attributionEnabled let attributionResolver = cliProxyAPIAttributionState.resolver let cliProxyUsageArtifactStamp = cliProxyAPIAttributionState.usageArtifactStamp let roots = self.defaultClaudeProjectsRoots(options: options) @@ -1054,6 +1016,7 @@ extension CostUsageScanner { providerFilter: options.claudeLogProviderFilter, attributionFilter: options.claudeAttributionFilter, cliProxyAPIConfigurationGeneration: cliProxyAPIConfigurationGeneration, + cliProxyAPIAttributionEnabled: cliProxyAPIAttributionEnabled, range: range, roots: roots, artifactStamps: ( @@ -1077,6 +1040,9 @@ extension CostUsageScanner { guard CostUsageCacheLocations.cliProxyAPIConfigurationGeneration( stateRoot: options.cacheRoot) == cliProxyAPIConfigurationGeneration else { throw CancellationError() } + guard !CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( + stateRoot: options.cacheRoot) == cliProxyAPIAttributionEnabled + else { throw CancellationError() } return priorMemo.report } } @@ -1224,6 +1190,7 @@ extension CostUsageScanner { providerFilter: providerFilter, attributionFilter: options.claudeAttributionFilter, cliProxyAPIConfigurationGeneration: cliProxyAPIConfigurationGeneration, + cliProxyAPIAttributionEnabled: cliProxyAPIAttributionEnabled, range: range, roots: roots, artifactStamps: ( @@ -1255,6 +1222,7 @@ extension CostUsageScanner { providerFilter: ClaudeLogProviderFilter, attributionFilter: ClaudeAttributionFilter, cliProxyAPIConfigurationGeneration: String?, + cliProxyAPIAttributionEnabled: Bool, range: CostUsageDayRange, roots: [URL], artifactStamps: ( @@ -1278,6 +1246,7 @@ extension CostUsageScanner { providerFilter: providerFilterKey, attributionFilter: attributionFilterKey, cliProxyAPIConfigurationGeneration: cliProxyAPIConfigurationGeneration, + cliProxyAPIAttributionEnabled: cliProxyAPIAttributionEnabled, sinceKey: range.sinceKey, untilKey: range.untilKey, scanSinceKey: range.scanSinceKey, diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+ClaudeCLIProxyAPIAttributionState.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+ClaudeCLIProxyAPIAttributionState.swift new file mode 100644 index 0000000000..1053f7d7bd --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+ClaudeCLIProxyAPIAttributionState.swift @@ -0,0 +1,42 @@ +extension CostUsageScanner { + struct ClaudeCLIProxyAPIAttributionState { + let configurationGeneration: String? + let attributionEnabled: Bool + let resolver: CLIProxyAPIAttributionResolver? + let usageArtifactStamp: CostUsageClaudeFileStamp? + } + + static func captureClaudeCLIProxyAPIAttributionState( + options: Options, + checkCancellation: CancellationCheck?) throws -> ClaudeCLIProxyAPIAttributionState + { + try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( + stateRoot: options.cacheRoot) + { + let configurationGeneration = CostUsageCacheLocations.cliProxyAPIConfigurationGeneration( + stateRoot: options.cacheRoot) + let attributionEnabled = !CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( + stateRoot: options.cacheRoot) + let attributionResolver: CLIProxyAPIAttributionResolver? + if attributionEnabled, let home = options.cliProxyAPIHome { + let usageRecords = CLIProxyAPIUsageCacheIO.loadAssumingInterprocessLockHeld( + cacheRoot: options.cacheRoot) + attributionResolver = try CLIProxyAPIAttributionResolver.load( + home: home, + cacheRoot: options.cacheRoot, + forceReload: options.forceRescan, + usageRecords: usageRecords, + checkCancellation: checkCancellation) + } else { + attributionResolver = nil + } + let usageArtifactStamp = CostUsageClaudeFileStamp.read( + at: CLIProxyAPIUsageCacheIO.cacheFileURL(cacheRoot: options.cacheRoot)) + return ClaudeCLIProxyAPIAttributionState( + configurationGeneration: configurationGeneration, + attributionEnabled: attributionEnabled, + resolver: attributionResolver, + usageArtifactStamp: usageArtifactStamp) + } + } +} diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index 3fc2a5762f..958d46b9ee 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -940,6 +940,53 @@ extension CLIProxyAPIUsageCacheTests { fileManager: fileManager)) } + @Test @MainActor + func `async disconnect isolation does not block the main actor on the interprocess lock`() async throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-main-actor-isolation-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: root) } + let generation = try #require(CostUsageCacheLocations + .prepareCLIProxyAPIConfigurationGenerationUpdate(stateRoot: root, fileManager: fileManager)) + #expect(CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate( + generation, + fileManager: fileManager)) + let lockAcquired = DispatchSemaphore(value: 0) + let releaseLock = DispatchSemaphore(value: 0) + let isolationStarted = DispatchSemaphore(value: 0) + let mainActorAdvanced = LockIsolated(false) + let advancedBeforeRelease = LockIsolated(false) + + let lockHolder = Task.detached { + try await CostUsageCacheLocations.withCLIProxyAPIInterprocessLock(stateRoot: root) { + lockAcquired.signal() + _ = await Self.waitForSignal(releaseLock, timeout: .distantFuture) + } + } + #expect(await Self.waitForSignal(lockAcquired, timeout: .now() + 1)) + let delayedRelease = Task.detached { + try? await Task.sleep(for: .milliseconds(250)) + advancedBeforeRelease.setValue(mainActorAdvanced.value) + releaseLock.signal() + } + let isolation = Task { @MainActor in + isolationStarted.signal() + return await CostUsageCacheLocations.publishCLIProxyAPIAttributionIsolationAsync( + expectedGeneration: generation.generation, + stateRoot: root) + } + #expect(await Self.waitForSignal(isolationStarted, timeout: .now() + 1)) + + await Task { @MainActor in + mainActorAdvanced.setValue(true) + }.value + + #expect(await isolation.value) + try await lockHolder.value + await delayedRelease.value + #expect(advancedBeforeRelease.value) + } + @Test func `usage retention clamps clock skew and rejects implausible future records`() throws { let fileManager = FileManager.default diff --git a/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift b/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift index 61ee78350c..c6389e9c92 100644 --- a/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift @@ -55,6 +55,42 @@ struct CostUsageScannerClaudeMemoTests { } } + @Test + func `memo hit rejects disconnect isolation after capture`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 1) + _ = try self.writeEvent(env: env, day: day, path: "project/session.jsonl", id: "first", input: 10) + let options = self.options(env: env) + _ = self.load(day: day, options: options) + + #expect(throws: CancellationError.self) { + _ = try CostUsageScanner.withClaudeReportMemoHitObserverForTesting { + #expect(CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected( + true, + stateRoot: env.cacheRoot)) + } operation: { + try CostUsageScanner.loadDailyReportCancellable( + provider: .claude, + since: day, + until: day, + now: day, + options: options, + checkCancellation: nil) + } + } + + let disconnected = try CostUsageScanner.loadDailyReportCancellable( + provider: .claude, + since: day, + until: day, + now: day, + options: options, + checkCancellation: nil) + #expect(disconnected.data.first?.totalTokens == 10) + #expect(disconnected.data.first?.modelBreakdowns?.first?.attribution?.route != .cliProxyAPI) + } + @Test func `telemetry appended after resolver capture is not memoized as stale attribution`() throws { let env = try CostUsageTestEnvironment() From cfd39aa8801ba9a2257677917600bfe0126d179e Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sun, 23 Aug 2026 07:05:27 -0700 Subject: [PATCH 127/183] Guard cached proxy hydration --- Sources/CodexBar/UsageStore+TokenCost.swift | 2 + Sources/CodexBarCore/CostUsageFetcher.swift | 8 ++++ .../UsageStoreCachedTokenHydrationTests.swift | 43 +++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 2d7bd54737..0e8513da09 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -253,6 +253,7 @@ extension UsageStore { let tokenSnapshotScopeSignature = self.tokenSnapshotScopeSignature(for: .codex) let tokenSnapshotPublicationRevision = self.tokenSnapshotPublicationRevision(for: .codex) let cliProxyAPIConfigurationGeneration = self.costUsageFetcher.cliProxyAPIConfigurationGeneration() + let cliProxyAPIUsageTelemetryRevision = self.costUsageFetcher.cliProxyAPIUsageTelemetryRevision() return Task { @MainActor [weak self] in guard let self else { return } guard self.tokenSnapshotPublicationForCurrentProviderConfig(for: .codex) == nil else { return } @@ -289,6 +290,7 @@ extension UsageStore { self.tokenSnapshotScopeSignature(for: .codex) == tokenSnapshotScopeSignature, self.tokenSnapshotPublicationRevision(for: .codex) == tokenSnapshotPublicationRevision, self.costUsageFetcher.cliProxyAPIConfigurationGeneration() == cliProxyAPIConfigurationGeneration, + self.costUsageFetcher.cliProxyAPIUsageTelemetryRevision() == cliProxyAPIUsageTelemetryRevision, self.tokenSnapshotPublicationForCurrentProviderConfig(for: .codex) == nil else { return diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 4924a7b29f..f79ae3e953 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -349,6 +349,14 @@ public struct CostUsageFetcher: Sendable { return CostUsageCacheLocations.cliProxyAPIConfigurationGeneration(stateRoot: options.cacheRoot) } + package func cliProxyAPIUsageTelemetryRevision() -> String? { + let options = Self.resolvedScannerOptions( + self.scannerOptionsOverride(), + provider: .codex, + codexHomePath: nil) + return CLIProxyAPIUsageTelemetryRevision.current(cacheRoot: options.cacheRoot) + } + package func codexScanCatchUpStatus( codexHomePath: String? = nil, calendar: Calendar? = nil) async -> CodexScanCatchUpStatus diff --git a/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift b/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift index 7e511f31c0..9e758ff253 100644 --- a/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift +++ b/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift @@ -307,6 +307,49 @@ struct UsageStoreCachedTokenHydrationTests { #expect(store.tokenLastAttemptAt(for: .codex) == nil) } + @Test + func `telemetry import wins over in flight cached codex hydration`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let now = Date(timeIntervalSince1970: 1_775_000_000) + let settings = Self.makeCodexOnlySettings(historyDays: 1) + let options = CostUsageScanner.Options(cacheRoot: env.cacheRoot) + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + costUsageFetcher: CostUsageFetcher(scannerOptions: options), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + let gate = CachedTokenHydrationGate() + store._test_cachedCodexTokenSnapshotLoaderOverride = { _, _, _ in + await gate.enter() + return (Self.cachedTokenSnapshot(), Date(), nil) + } + + let hydration = store.hydrateCachedTokenSnapshots(now: now) + await gate.waitForStart() + #expect(CLIProxyAPIUsageCacheIO.merge( + [ + CLIProxyAPIUsageRecord( + timestamp: now, + provider: "codex", + model: "gpt-5.4", + alias: "gpt-5.4", + endpoint: "POST /v1/messages", + authType: "oauth", + requestID: "hydration-race", + tokens: .init(input: 10, output: 20, total: 30)), + ], + cacheRoot: env.cacheRoot, + now: now) == 1) + await gate.release() + await hydration?.value + + #expect(store.tokenSnapshot(for: .codex) == nil) + #expect(store.tokenLastAttemptAt(for: .codex) == nil) + } + private static func makeCodexOnlySettings(historyDays: Int) -> SettingsStore { let suite = "UsageStoreCachedTokenHydrationTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! From 90014e43649c6c4224dad670bca977b8c823696b Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sun, 23 Aug 2026 18:59:56 -0700 Subject: [PATCH 128/183] Reject stale proxy telemetry reports --- .../CostUsage/CostUsageScanner+Claude.swift | 6 ++- .../CostUsageScannerClaudeMemoTests.swift | 53 ++++++++++++++++--- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 00d32b04fd..d7fb8fcc5d 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -985,7 +985,7 @@ extension CostUsageScanner { return inventory } - // swiftlint:disable:next function_body_length + // swiftlint:disable:next cyclomatic_complexity function_body_length static func loadClaudeDaily( provider: UsageProvider, range: CostUsageDayRange, @@ -1043,6 +1043,8 @@ extension CostUsageScanner { guard !CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( stateRoot: options.cacheRoot) == cliProxyAPIAttributionEnabled else { throw CancellationError() } + guard CostUsageClaudeFileStamp.read(at: cliProxyUsageURL) == cliProxyUsageArtifactStamp + else { throw CancellationError() } return priorMemo.report } } @@ -1145,6 +1147,8 @@ extension CostUsageScanner { guard CostUsageCacheLocations.cliProxyAPIConfigurationGeneration( stateRoot: options.cacheRoot) == cliProxyAPIConfigurationGeneration else { throw CancellationError() } + guard CostUsageClaudeFileStamp.read(at: cliProxyUsageURL) == cliProxyUsageArtifactStamp + else { throw CancellationError() } let reportAttributionEnabled = !CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( stateRoot: options.cacheRoot) diff --git a/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift b/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift index c6389e9c92..51eb72ed54 100644 --- a/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift @@ -92,7 +92,41 @@ struct CostUsageScannerClaudeMemoTests { } @Test - func `telemetry appended after resolver capture is not memoized as stale attribution`() throws { + func `memo hit rejects telemetry change after capture`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 1) + _ = try self.writeEvent(env: env, day: day, path: "project/session.jsonl", id: "first", input: 10) + let options = self.options(env: env) + _ = self.load(day: day, options: options) + let usageRecord = CLIProxyAPIUsageRecord( + timestamp: day, + provider: "codex", + executorType: "CodexExecutor", + model: "gpt-5.5", + alias: "gpt-5.5", + endpoint: "/v1/messages", + authType: "oauth", + requestID: "memo-hit-request", + tokens: .init(input: 10, output: 0, total: 10)) + + #expect(throws: CancellationError.self) { + _ = try CostUsageScanner.withClaudeCLIProxyAPIAttributionCaptureObserverForTesting { + #expect(CLIProxyAPIUsageCacheIO.merge([usageRecord], cacheRoot: env.cacheRoot, now: day) == 1) + } operation: { + try CostUsageScanner.loadDailyReportCancellable( + provider: .claude, + since: day, + until: day, + now: day, + options: options, + checkCancellation: nil) + } + } + } + + @Test + func `telemetry appended after resolver capture rejects stale attribution`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 7, day: 1) @@ -130,14 +164,21 @@ struct CostUsageScannerClaudeMemoTests { requestID: "proxy-request", tokens: .init(input: 100, output: 0, total: 100)) - let stale = CostUsageScanner.withClaudeCLIProxyAPIAttributionCaptureObserverForTesting { - #expect(CLIProxyAPIUsageCacheIO.merge([usageRecord], cacheRoot: env.cacheRoot, now: day) == 1) - } operation: { - self.load(day: day, options: options) + #expect(throws: CancellationError.self) { + _ = try CostUsageScanner.withClaudeCLIProxyAPIAttributionCaptureObserverForTesting { + #expect(CLIProxyAPIUsageCacheIO.merge([usageRecord], cacheRoot: env.cacheRoot, now: day) == 1) + } operation: { + try CostUsageScanner.loadDailyReportCancellable( + provider: .claude, + since: day, + until: day, + now: day, + options: options, + checkCancellation: nil) + } } let refreshed = self.load(day: day, options: options) - #expect(stale.data.first?.modelBreakdowns?.first?.attribution?.upstream == nil) #expect(refreshed.data.first?.modelBreakdowns?.first?.attribution?.upstream?.provider == "codex") } From 26008c03f6d9ce8f96b9414b582b7a4257eb62f1 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 24 Aug 2026 07:20:30 -0700 Subject: [PATCH 129/183] Harden proxy attribution state changes --- .../CLIProxyAPIAttributionResolver.swift | 53 +++++++++++++++++-- .../CLIProxyAPIUsageTelemetry.swift | 9 ++++ .../CostUsageCacheLocations.swift | 46 ++++++++++++++++ .../CostUsage/CostUsageClaudeCache.swift | 1 + .../CostUsage/CostUsageScanner+Claude.swift | 29 +++++++++- ...er+ClaudeCLIProxyAPIAttributionState.swift | 15 +++++- .../CLIProxyAPIUsageCacheTests.swift | 43 ++++++++++++++- .../CostUsageScannerClaudeMemoTests.swift | 29 ++++++++++ 8 files changed, 218 insertions(+), 7 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift index 9deb8db684..a9ea940f19 100644 --- a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift +++ b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift @@ -69,13 +69,15 @@ struct CLIProxyAPIAttributionResolver: Sendable { private let authProviders: [AuthProvider] private let codexOAuthModelRoutes: [String: String] private let hasConfiguredOpenAIAPIUpstream: Bool + let inputArtifactFingerprint: [String: CostUsageClaudeFileStamp] init( observations: [Observation], usageRecords: [CLIProxyAPIUsageRecord] = [], authProviders: [AuthProvider] = [], codexOAuthModelAliases: [String: String] = [:], - hasConfiguredOpenAIAPIUpstream: Bool = false) + hasConfiguredOpenAIAPIUpstream: Bool = false, + inputArtifactFingerprint: [String: CostUsageClaudeFileStamp] = [:]) { self.observationsBySessionID = Dictionary(grouping: observations, by: \.sessionID) self.observationsByCanonicalModel = Dictionary( @@ -90,6 +92,7 @@ struct CLIProxyAPIAttributionResolver: Sendable { result[Self.canonicalModel(upstreamModel)] = upstreamModel } self.hasConfiguredOpenAIAPIUpstream = hasConfiguredOpenAIAPIUpstream + self.inputArtifactFingerprint = inputArtifactFingerprint } static func load( @@ -100,19 +103,30 @@ struct CLIProxyAPIAttributionResolver: Sendable { usageRecords: [CLIProxyAPIUsageRecord]? = nil, checkCancellation: (() throws -> Void)? = nil) throws -> Self { + let inputArtifactFingerprint = try self.inputArtifactFingerprint( + home: home, + fileManager: fileManager, + checkCancellation: checkCancellation) let observations = try self.loadObservations( logDirectory: home.appendingPathComponent("logs", isDirectory: true), fileManager: fileManager, forceReload: forceReload, checkCancellation: checkCancellation) - return Self( + let resolver = Self( observations: observations, usageRecords: usageRecords ?? CLIProxyAPIUsageCacheIO.load(cacheRoot: cacheRoot), authProviders: self.loadAuthProviders(home: home, fileManager: fileManager), codexOAuthModelAliases: self.loadCodexOAuthModelAliases(home: home, fileManager: fileManager), hasConfiguredOpenAIAPIUpstream: self.hasConfiguredOpenAIAPIUpstream( home: home, - fileManager: fileManager)) + fileManager: fileManager), + inputArtifactFingerprint: inputArtifactFingerprint) + guard try inputArtifactFingerprint == self.inputArtifactFingerprint( + home: home, + fileManager: fileManager, + checkCancellation: checkCancellation) + else { throw CancellationError() } + return resolver } func attribution( @@ -852,3 +866,36 @@ struct CLIProxyAPIAttributionResolver: Sendable { .lowercased() } } + +extension CLIProxyAPIAttributionResolver { + static func inputArtifactFingerprint( + home: URL, + fileManager: FileManager = .default, + checkCancellation: (() throws -> Void)? = nil) throws -> [String: CostUsageClaudeFileStamp] + { + var urls = [home.appendingPathComponent("config.yaml", isDirectory: false)] + if let authURLs = try? fileManager.contentsOfDirectory( + at: home, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles]) + { + urls.append(contentsOf: authURLs.filter { $0.pathExtension.lowercased() == "json" }) + } + let logDirectory = home.appendingPathComponent("logs", isDirectory: true) + if let logURLs = try? fileManager.contentsOfDirectory( + at: logDirectory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles]) + { + urls.append(contentsOf: logURLs.filter { $0.pathExtension.lowercased() == "log" }) + } + + var fingerprint: [String: CostUsageClaudeFileStamp] = [:] + for url in urls { + try checkCancellation?() + guard let stamp = CostUsageClaudeFileStamp.read(at: url) else { continue } + fingerprint[url.standardizedFileURL.path] = stamp + } + return fingerprint + } +} diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index dcf46294f6..8d1a882581 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -835,6 +835,7 @@ public enum CLIProxyAPIConnectionSettingsStore { fileManager: fileManager, disconnectedStateAfterCommit: false, disconnectedStateAfterRollback: wasDisconnected, + replacementCredentialsStored: false, prepareState: { operations.setDisconnectedState(true) }) else { _ = operations.setDisconnectedState(wasDisconnected) @@ -879,6 +880,14 @@ public enum CLIProxyAPIConnectionSettingsStore { rollback() return false } + if let artifactsUpdate, + !CostUsageCacheLocations.markCLIProxyAPIArtifactsReplacementCredentialsStored( + artifactsUpdate, + fileManager: fileManager) + { + rollback() + return false + } guard operations.setDisconnectedState(false) else { rollback() return false diff --git a/Sources/CodexBarCore/CostUsageCacheLocations.swift b/Sources/CodexBarCore/CostUsageCacheLocations.swift index ad0ff5a6b2..ac9d2a08af 100644 --- a/Sources/CodexBarCore/CostUsageCacheLocations.swift +++ b/Sources/CodexBarCore/CostUsageCacheLocations.swift @@ -44,6 +44,7 @@ public enum CostUsageCacheLocations { let moves: [Move] let disconnectedStateAfterCommit: Bool? let disconnectedStateAfterRollback: Bool? + let replacementCredentialsStored: Bool? let forceRollback: Bool? let rollbackCredentialsRestored: Bool? let removalIsolationPublished: Bool? @@ -213,6 +214,7 @@ public enum CostUsageCacheLocations { fileManager: FileManager, disconnectedStateAfterCommit: Bool? = nil, disconnectedStateAfterRollback: Bool? = nil, + replacementCredentialsStored: Bool? = nil, removalIsolationPublished: Bool? = nil, removalCredentialsCleared: Bool? = nil, prepareState: () -> Bool = { true }) -> CLIProxyAPIArtifactsUpdate? @@ -239,6 +241,7 @@ public enum CostUsageCacheLocations { }, disconnectedStateAfterCommit: disconnectedStateAfterCommit, disconnectedStateAfterRollback: disconnectedStateAfterRollback, + replacementCredentialsStored: replacementCredentialsStored, forceRollback: nil, rollbackCredentialsRestored: nil, removalIsolationPublished: removalIsolationPublished, @@ -323,6 +326,7 @@ public enum CostUsageCacheLocations { moves: manifest.moves, disconnectedStateAfterCommit: manifest.disconnectedStateAfterCommit, disconnectedStateAfterRollback: manifest.disconnectedStateAfterRollback, + replacementCredentialsStored: manifest.replacementCredentialsStored, forceRollback: true, rollbackCredentialsRestored: manifest.rollbackCredentialsRestored, removalIsolationPublished: manifest.removalIsolationPublished, @@ -351,6 +355,7 @@ public enum CostUsageCacheLocations { moves: manifest.moves, disconnectedStateAfterCommit: manifest.disconnectedStateAfterCommit, disconnectedStateAfterRollback: manifest.disconnectedStateAfterRollback, + replacementCredentialsStored: manifest.replacementCredentialsStored, forceRollback: true, rollbackCredentialsRestored: true, removalIsolationPublished: manifest.removalIsolationPublished, @@ -363,6 +368,37 @@ public enum CostUsageCacheLocations { } } + @discardableResult + static func markCLIProxyAPIArtifactsReplacementCredentialsStored( + _ update: CLIProxyAPIArtifactsUpdate, + fileManager: FileManager) -> Bool + { + guard let manifestURL = update.manifestURL else { return true } + guard let data = try? Data(contentsOf: manifestURL), + let manifest = try? JSONDecoder().decode(CLIProxyAPIArtifactsTransactionManifest.self, from: data) + else { return false } + guard manifest.forceRollback != true else { return false } + guard manifest.replacementCredentialsStored == false else { + return manifest.replacementCredentialsStored == true + } + let storedManifest = CLIProxyAPIArtifactsTransactionManifest( + expectedGeneration: manifest.expectedGeneration, + moves: manifest.moves, + disconnectedStateAfterCommit: manifest.disconnectedStateAfterCommit, + disconnectedStateAfterRollback: manifest.disconnectedStateAfterRollback, + replacementCredentialsStored: true, + forceRollback: manifest.forceRollback, + rollbackCredentialsRestored: manifest.rollbackCredentialsRestored, + removalIsolationPublished: manifest.removalIsolationPublished, + removalCredentialsCleared: manifest.removalCredentialsCleared) + do { + try JSONEncoder().encode(storedManifest).write(to: manifestURL, options: [.atomic]) + return true + } catch { + return false + } + } + @discardableResult static func markCLIProxyAPIArtifactsRemovalCredentialsCleared( _ update: CLIProxyAPIArtifactsUpdate, @@ -381,6 +417,7 @@ public enum CostUsageCacheLocations { moves: manifest.moves, disconnectedStateAfterCommit: manifest.disconnectedStateAfterCommit, disconnectedStateAfterRollback: manifest.disconnectedStateAfterRollback, + replacementCredentialsStored: manifest.replacementCredentialsStored, forceRollback: manifest.forceRollback, rollbackCredentialsRestored: manifest.rollbackCredentialsRestored, removalIsolationPublished: manifest.removalIsolationPublished, @@ -411,6 +448,7 @@ public enum CostUsageCacheLocations { moves: manifest.moves, disconnectedStateAfterCommit: manifest.disconnectedStateAfterCommit, disconnectedStateAfterRollback: manifest.disconnectedStateAfterRollback, + replacementCredentialsStored: manifest.replacementCredentialsStored, forceRollback: manifest.forceRollback, rollbackCredentialsRestored: manifest.rollbackCredentialsRestored, removalIsolationPublished: true, @@ -493,6 +531,14 @@ public enum CostUsageCacheLocations { let didCommit = manifest.forceRollback != true && self.cliProxyAPIConfigurationGeneration(stateRoot: stateRoot, fileManager: fileManager) == manifest.expectedGeneration + if didCommit, manifest.replacementCredentialsStored == false { + guard self.setCLIProxyAPIExplicitlyDisconnected( + manifest.disconnectedStateAfterRollback ?? true, + stateRoot: stateRoot, + fileManager: fileManager) + else { return false } + return self.restoreCLIProxyAPIArtifactsUpdate(update, fileManager: fileManager) + } if didCommit, manifest.removalIsolationPublished != nil, manifest.removalIsolationPublished != true { guard self.setCLIProxyAPIExplicitlyDisconnected( manifest.disconnectedStateAfterRollback ?? true, diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift index 5c7a7fb325..90e59a102e 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift @@ -44,6 +44,7 @@ struct CostUsageClaudeReportMemoKey: Equatable, Sendable { let cacheArtifactStamp: CostUsageClaudeFileStamp? let pricingArtifactStamp: CostUsageClaudeFileStamp? let cliProxyUsageArtifactStamp: CostUsageClaudeFileStamp? + let cliProxyAPIInputArtifactFingerprint: [String: CostUsageClaudeFileStamp]? var scanConfiguration: ScanConfiguration { ScanConfiguration( diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index d7fb8fcc5d..fb68649adb 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -1001,6 +1001,7 @@ extension CostUsageScanner { let cliProxyAPIAttributionEnabled = cliProxyAPIAttributionState.attributionEnabled let attributionResolver = cliProxyAPIAttributionState.resolver let cliProxyUsageArtifactStamp = cliProxyAPIAttributionState.usageArtifactStamp + let cliProxyAPIInputArtifactFingerprint = cliProxyAPIAttributionState.inputArtifactFingerprint let roots = self.defaultClaudeProjectsRoots(options: options) let inventory = try Self.inventoryClaudeRoots(roots, checkCancellation: checkCancellation) try checkCancellation?() @@ -1017,6 +1018,7 @@ extension CostUsageScanner { attributionFilter: options.claudeAttributionFilter, cliProxyAPIConfigurationGeneration: cliProxyAPIConfigurationGeneration, cliProxyAPIAttributionEnabled: cliProxyAPIAttributionEnabled, + cliProxyAPIInputArtifactFingerprint: cliProxyAPIInputArtifactFingerprint, range: range, roots: roots, artifactStamps: ( @@ -1045,6 +1047,11 @@ extension CostUsageScanner { else { throw CancellationError() } guard CostUsageClaudeFileStamp.read(at: cliProxyUsageURL) == cliProxyUsageArtifactStamp else { throw CancellationError() } + guard try Self.currentClaudeCLIProxyAPIInputArtifactFingerprint( + options: options, + attributionEnabled: cliProxyAPIAttributionEnabled, + checkCancellation: checkCancellation) == cliProxyAPIInputArtifactFingerprint + else { throw CancellationError() } return priorMemo.report } } @@ -1149,6 +1156,11 @@ extension CostUsageScanner { else { throw CancellationError() } guard CostUsageClaudeFileStamp.read(at: cliProxyUsageURL) == cliProxyUsageArtifactStamp else { throw CancellationError() } + guard try Self.currentClaudeCLIProxyAPIInputArtifactFingerprint( + options: options, + attributionEnabled: cliProxyAPIAttributionEnabled, + checkCancellation: checkCancellation) == cliProxyAPIInputArtifactFingerprint + else { throw CancellationError() } let reportAttributionEnabled = !CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( stateRoot: options.cacheRoot) @@ -1183,18 +1195,28 @@ extension CostUsageScanner { guard CostUsageCacheLocations.cliProxyAPIConfigurationGeneration( stateRoot: options.cacheRoot) == cliProxyAPIConfigurationGeneration else { throw CancellationError() } + guard try Self.currentClaudeCLIProxyAPIInputArtifactFingerprint( + options: options, + attributionEnabled: cliProxyAPIAttributionEnabled, + checkCancellation: checkCancellation) == cliProxyAPIInputArtifactFingerprint + else { throw CancellationError() } return built } let finalCacheArtifactStamp = CostUsageClaudeFileStamp.read(at: cacheURL) let finalPricingArtifactStamp = CostUsageClaudeFileStamp.read(at: pricingURL) let finalCLIProxyUsageArtifactStamp = CostUsageClaudeFileStamp.read(at: cliProxyUsageURL) + let finalCLIProxyAPIInputArtifactFingerprint = try Self.currentClaudeCLIProxyAPIInputArtifactFingerprint( + options: options, + attributionEnabled: cliProxyAPIAttributionEnabled, + checkCancellation: checkCancellation) let finalReportKey = Self.claudeReportMemoKey( provider: provider, providerFilter: providerFilter, attributionFilter: options.claudeAttributionFilter, cliProxyAPIConfigurationGeneration: cliProxyAPIConfigurationGeneration, cliProxyAPIAttributionEnabled: cliProxyAPIAttributionEnabled, + cliProxyAPIInputArtifactFingerprint: finalCLIProxyAPIInputArtifactFingerprint, range: range, roots: roots, artifactStamps: ( @@ -1208,7 +1230,8 @@ extension CostUsageScanner { } if cacheArtifactIsCurrent, finalPricingArtifactStamp == pricingArtifactStamp, - finalCLIProxyUsageArtifactStamp == cliProxyUsageArtifactStamp + finalCLIProxyUsageArtifactStamp == cliProxyUsageArtifactStamp, + finalCLIProxyAPIInputArtifactFingerprint == cliProxyAPIInputArtifactFingerprint { memo.store( provider: provider, @@ -1227,6 +1250,7 @@ extension CostUsageScanner { attributionFilter: ClaudeAttributionFilter, cliProxyAPIConfigurationGeneration: String?, cliProxyAPIAttributionEnabled: Bool, + cliProxyAPIInputArtifactFingerprint: [String: CostUsageClaudeFileStamp]?, range: CostUsageDayRange, roots: [URL], artifactStamps: ( @@ -1259,7 +1283,8 @@ extension CostUsageScanner { roots: roots.map { $0.standardizedFileURL.resolvingSymlinksInPath().path }.sorted(), cacheArtifactStamp: artifactStamps.cache, pricingArtifactStamp: artifactStamps.pricing, - cliProxyUsageArtifactStamp: artifactStamps.proxyUsage) + cliProxyUsageArtifactStamp: artifactStamps.proxyUsage, + cliProxyAPIInputArtifactFingerprint: cliProxyAPIInputArtifactFingerprint) } private struct ClaudeReportAggregation { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+ClaudeCLIProxyAPIAttributionState.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+ClaudeCLIProxyAPIAttributionState.swift index 1053f7d7bd..0ced5e7577 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+ClaudeCLIProxyAPIAttributionState.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+ClaudeCLIProxyAPIAttributionState.swift @@ -4,6 +4,7 @@ extension CostUsageScanner { let attributionEnabled: Bool let resolver: CLIProxyAPIAttributionResolver? let usageArtifactStamp: CostUsageClaudeFileStamp? + let inputArtifactFingerprint: [String: CostUsageClaudeFileStamp]? } static func captureClaudeCLIProxyAPIAttributionState( @@ -36,7 +37,19 @@ extension CostUsageScanner { configurationGeneration: configurationGeneration, attributionEnabled: attributionEnabled, resolver: attributionResolver, - usageArtifactStamp: usageArtifactStamp) + usageArtifactStamp: usageArtifactStamp, + inputArtifactFingerprint: attributionResolver?.inputArtifactFingerprint) } } + + static func currentClaudeCLIProxyAPIInputArtifactFingerprint( + options: Options, + attributionEnabled: Bool, + checkCancellation: CancellationCheck? = nil) throws -> [String: CostUsageClaudeFileStamp]? + { + guard attributionEnabled, let home = options.cliProxyAPIHome else { return nil } + return try CLIProxyAPIAttributionResolver.inputArtifactFingerprint( + home: home, + checkCancellation: checkCancellation) + } } diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index 958d46b9ee..99bbe814f9 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -1357,10 +1357,16 @@ struct CLIProxyAPITransactionRecoveryTests { in: [costUsage], stateRoot: root, expectedGeneration: generationUpdate.generation, - fileManager: fileManager)) + fileManager: fileManager, + disconnectedStateAfterCommit: false, + disconnectedStateAfterRollback: false, + replacementCredentialsStored: false)) #expect(CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate( generationUpdate, fileManager: fileManager)) + #expect(CostUsageCacheLocations.markCLIProxyAPIArtifactsReplacementCredentialsStored( + artifactsUpdate, + fileManager: fileManager)) try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock(stateRoot: root, fileManager: fileManager) {} @@ -1369,6 +1375,41 @@ struct CLIProxyAPITransactionRecoveryTests { #expect(artifactsUpdate.manifestURL.map { !fileManager.fileExists(atPath: $0.path) } == true) } + @Test + func `interrupted replacement before credential checkpoint restores staged artifacts`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-replacement-before-store-\(UUID().uuidString)", isDirectory: true) + let costUsage = root.appendingPathComponent("cost-usage", isDirectory: true) + let usageFile = costUsage.appendingPathComponent(CostUsageCacheLocations.cliProxyAPIUsageFileName) + try fileManager.createDirectory(at: costUsage, withIntermediateDirectories: true) + try Data("telemetry".utf8).write(to: usageFile) + defer { try? fileManager.removeItem(at: root) } + + let generationUpdate = try #require(CostUsageCacheLocations + .prepareCLIProxyAPIConfigurationGenerationUpdate(stateRoot: root, fileManager: fileManager)) + let artifactsUpdate = try #require(CostUsageCacheLocations.prepareCLIProxyAPIArtifactsUpdate( + in: [costUsage], + stateRoot: root, + expectedGeneration: generationUpdate.generation, + fileManager: fileManager, + disconnectedStateAfterCommit: false, + disconnectedStateAfterRollback: false, + replacementCredentialsStored: false)) + #expect(CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate( + generationUpdate, + fileManager: fileManager)) + + try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock(stateRoot: root, fileManager: fileManager) {} + + #expect(fileManager.fileExists(atPath: usageFile.path)) + #expect(!CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( + stateRoot: root, + fileManager: fileManager)) + #expect(artifactsUpdate.moves.allSatisfy { !fileManager.fileExists(atPath: $0.stagedURL.path) }) + #expect(artifactsUpdate.manifestURL.map { !fileManager.fileExists(atPath: $0.path) } == true) + } + @Test func `interrupted removal before isolation restores telemetry and connection state`() throws { let fileManager = FileManager.default diff --git a/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift b/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift index 51eb72ed54..82705a05c4 100644 --- a/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift @@ -125,6 +125,35 @@ struct CostUsageScannerClaudeMemoTests { } } + @Test + func `memo hit rejects proxy routing input change after capture`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 1) + _ = try self.writeEvent(env: env, day: day, path: "project/session.jsonl", id: "first", input: 10) + let cliProxyHome = env.root.appendingPathComponent("cli-proxy-api", isDirectory: true) + try FileManager.default.createDirectory(at: cliProxyHome, withIntermediateDirectories: true) + let configurationURL = cliProxyHome.appendingPathComponent("config.yaml") + try Data("codex-api-key: []\n".utf8).write(to: configurationURL) + var options = self.options(env: env) + options.cliProxyAPIHome = cliProxyHome + _ = self.load(day: day, options: options) + + #expect(throws: CancellationError.self) { + _ = try CostUsageScanner.withClaudeReportMemoHitObserverForTesting { + try? Data("openai-compatibility: []\n".utf8).write(to: configurationURL, options: [.atomic]) + } operation: { + try CostUsageScanner.loadDailyReportCancellable( + provider: .claude, + since: day, + until: day, + now: day, + options: options, + checkCancellation: nil) + } + } + } + @Test func `telemetry appended after resolver capture rejects stale attribution`() throws { let env = try CostUsageTestEnvironment() From 814ae7bd486cc64c0d952daf372b3f0ce03bd7de Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 24 Aug 2026 07:45:40 -0700 Subject: [PATCH 130/183] Close proxy recovery crash windows --- .../CLIProxyAPIAttributionResolver.swift | 37 +++++++------ .../CLIProxyAPIUsageTelemetry.swift | 17 ++++++ .../CostUsageCacheLocations.swift | 40 +++++++++++++- .../CLIProxyAPIAttributionResolverTests.swift | 46 ++++++++++++++++ .../CLIProxyAPIUsageCacheTests.swift | 53 ++++++++++++++++++- 5 files changed, 175 insertions(+), 18 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift index a9ea940f19..bbfe59c473 100644 --- a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift +++ b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift @@ -521,22 +521,6 @@ struct CLIProxyAPIAttributionResolver: Sendable { model: model.trimmingCharacters(in: .whitespacesAndNewlines)) } - private static func tokensMatch( - _ tokens: TokenSignature, - _ telemetry: CLIProxyAPIUsageRecord.Tokens) -> Bool - { - guard telemetry.output == tokens.output else { return false } - if telemetry.cacheRead != 0 || telemetry.cacheCreation != 0 { - return telemetry.input == tokens.input - && telemetry.cacheRead == tokens.cacheRead - && telemetry.cacheCreation == tokens.cacheCreate - } - let claudeInputTotal = tokens.input + tokens.cacheRead + tokens.cacheCreate - return telemetry.input == tokens.input - || telemetry.input == claudeInputTotal - || telemetry.input + telemetry.cached == claudeInputTotal - } - private static func uniqueClosest( _ candidates: [T], target: Date, @@ -868,6 +852,27 @@ struct CLIProxyAPIAttributionResolver: Sendable { } extension CLIProxyAPIAttributionResolver { + private static func tokensMatch( + _ tokens: TokenSignature, + _ telemetry: CLIProxyAPIUsageRecord.Tokens) -> Bool + { + guard telemetry.output == tokens.output else { return false } + if telemetry.cacheRead != 0 || telemetry.cacheCreation != 0 { + return telemetry.input == tokens.input + && telemetry.cacheRead == tokens.cacheRead + && telemetry.cacheCreation == tokens.cacheCreate + } + let (inputAndCacheRead, inputAndCacheReadOverflow) = tokens.input.addingReportingOverflow(tokens.cacheRead) + guard !inputAndCacheReadOverflow else { return false } + let (claudeInputTotal, claudeInputTotalOverflow) = inputAndCacheRead.addingReportingOverflow(tokens.cacheCreate) + guard !claudeInputTotalOverflow else { return false } + let (telemetryInputTotal, telemetryInputTotalOverflow) = telemetry.input + .addingReportingOverflow(telemetry.cached) + return telemetry.input == tokens.input + || telemetry.input == claudeInputTotal + || (!telemetryInputTotalOverflow && telemetryInputTotal == claudeInputTotal) + } + static func inputArtifactFingerprint( home: URL, fileManager: FileManager = .default, diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 8d1a882581..6eb36e3cbc 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -775,6 +775,21 @@ public enum CLIProxyAPIConnectionSettingsStore { } } + static func credentialFingerprint(_ settings: CLIProxyAPIConnectionSettings) -> String? { + try? CanonicalSyncJSON.hash(settings) + } + + static func replacementCredentialMatches(fingerprint: String) -> Bool? { + switch KeychainCacheStore.load(key: self.key, as: CLIProxyAPIConnectionSettings.self) { + case let .found(settings): + self.credentialFingerprint(settings) == fingerprint + case .missing: + false + case .temporarilyUnavailable, .invalid: + nil + } + } + static func artifactDisposition( _ settings: CLIProxyAPIConnectionSettings, isDisconnected: Bool, @@ -801,6 +816,7 @@ public enum CLIProxyAPIConnectionSettingsStore { operations: SerializedSaveOperations) -> Bool { guard settings.isConfigured else { return false } + guard let replacementCredentialFingerprint = self.credentialFingerprint(settings) else { return false } do { return try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( stateRoot: stateRoot, @@ -835,6 +851,7 @@ public enum CLIProxyAPIConnectionSettingsStore { fileManager: fileManager, disconnectedStateAfterCommit: false, disconnectedStateAfterRollback: wasDisconnected, + replacementCredentialFingerprint: replacementCredentialFingerprint, replacementCredentialsStored: false, prepareState: { operations.setDisconnectedState(true) }) else { diff --git a/Sources/CodexBarCore/CostUsageCacheLocations.swift b/Sources/CodexBarCore/CostUsageCacheLocations.swift index ac9d2a08af..cebaaa5af5 100644 --- a/Sources/CodexBarCore/CostUsageCacheLocations.swift +++ b/Sources/CodexBarCore/CostUsageCacheLocations.swift @@ -44,6 +44,7 @@ public enum CostUsageCacheLocations { let moves: [Move] let disconnectedStateAfterCommit: Bool? let disconnectedStateAfterRollback: Bool? + let replacementCredentialFingerprint: String? let replacementCredentialsStored: Bool? let forceRollback: Bool? let rollbackCredentialsRestored: Bool? @@ -118,6 +119,7 @@ public enum CostUsageCacheLocations { stateRoot: URL?, fileManager: FileManager = .default, recoverRemovalConfiguration: (() -> Bool)? = nil, + recoverReplacementConfiguration: ((String) -> Bool?)? = nil, operation: () throws -> T) throws -> T { let descriptor = try self.acquireCLIProxyAPILock(stateRoot: stateRoot, fileManager: fileManager) @@ -125,6 +127,7 @@ public enum CostUsageCacheLocations { guard self.recoverCLIProxyAPIArtifactsTransaction( stateRoot: stateRoot, fileManager: fileManager, + recoverReplacementConfiguration: recoverReplacementConfiguration, recoverRemovalConfiguration: recoverRemovalConfiguration ?? { CLIProxyAPIConnectionSettingsStore.recoverInterruptedRemovalUnserialized( stateRoot: stateRoot, @@ -140,6 +143,7 @@ public enum CostUsageCacheLocations { stateRoot: URL?, fileManager: FileManager = .default, recoverRemovalConfiguration: (() -> Bool)? = nil, + recoverReplacementConfiguration: ((String) -> Bool?)? = nil, operation: () async throws -> T) async throws -> T { let descriptor = try self.acquireCLIProxyAPILock(stateRoot: stateRoot, fileManager: fileManager) @@ -147,6 +151,7 @@ public enum CostUsageCacheLocations { guard self.recoverCLIProxyAPIArtifactsTransaction( stateRoot: stateRoot, fileManager: fileManager, + recoverReplacementConfiguration: recoverReplacementConfiguration, recoverRemovalConfiguration: recoverRemovalConfiguration ?? { CLIProxyAPIConnectionSettingsStore.recoverInterruptedRemovalUnserialized( stateRoot: stateRoot, @@ -214,6 +219,7 @@ public enum CostUsageCacheLocations { fileManager: FileManager, disconnectedStateAfterCommit: Bool? = nil, disconnectedStateAfterRollback: Bool? = nil, + replacementCredentialFingerprint: String? = nil, replacementCredentialsStored: Bool? = nil, removalIsolationPublished: Bool? = nil, removalCredentialsCleared: Bool? = nil, @@ -241,6 +247,7 @@ public enum CostUsageCacheLocations { }, disconnectedStateAfterCommit: disconnectedStateAfterCommit, disconnectedStateAfterRollback: disconnectedStateAfterRollback, + replacementCredentialFingerprint: replacementCredentialFingerprint, replacementCredentialsStored: replacementCredentialsStored, forceRollback: nil, rollbackCredentialsRestored: nil, @@ -326,6 +333,7 @@ public enum CostUsageCacheLocations { moves: manifest.moves, disconnectedStateAfterCommit: manifest.disconnectedStateAfterCommit, disconnectedStateAfterRollback: manifest.disconnectedStateAfterRollback, + replacementCredentialFingerprint: manifest.replacementCredentialFingerprint, replacementCredentialsStored: manifest.replacementCredentialsStored, forceRollback: true, rollbackCredentialsRestored: manifest.rollbackCredentialsRestored, @@ -355,6 +363,7 @@ public enum CostUsageCacheLocations { moves: manifest.moves, disconnectedStateAfterCommit: manifest.disconnectedStateAfterCommit, disconnectedStateAfterRollback: manifest.disconnectedStateAfterRollback, + replacementCredentialFingerprint: manifest.replacementCredentialFingerprint, replacementCredentialsStored: manifest.replacementCredentialsStored, forceRollback: true, rollbackCredentialsRestored: true, @@ -386,6 +395,7 @@ public enum CostUsageCacheLocations { moves: manifest.moves, disconnectedStateAfterCommit: manifest.disconnectedStateAfterCommit, disconnectedStateAfterRollback: manifest.disconnectedStateAfterRollback, + replacementCredentialFingerprint: manifest.replacementCredentialFingerprint, replacementCredentialsStored: true, forceRollback: manifest.forceRollback, rollbackCredentialsRestored: manifest.rollbackCredentialsRestored, @@ -417,6 +427,7 @@ public enum CostUsageCacheLocations { moves: manifest.moves, disconnectedStateAfterCommit: manifest.disconnectedStateAfterCommit, disconnectedStateAfterRollback: manifest.disconnectedStateAfterRollback, + replacementCredentialFingerprint: manifest.replacementCredentialFingerprint, replacementCredentialsStored: manifest.replacementCredentialsStored, forceRollback: manifest.forceRollback, rollbackCredentialsRestored: manifest.rollbackCredentialsRestored, @@ -448,6 +459,7 @@ public enum CostUsageCacheLocations { moves: manifest.moves, disconnectedStateAfterCommit: manifest.disconnectedStateAfterCommit, disconnectedStateAfterRollback: manifest.disconnectedStateAfterRollback, + replacementCredentialFingerprint: manifest.replacementCredentialFingerprint, replacementCredentialsStored: manifest.replacementCredentialsStored, forceRollback: manifest.forceRollback, rollbackCredentialsRestored: manifest.rollbackCredentialsRestored, @@ -504,6 +516,7 @@ public enum CostUsageCacheLocations { static func recoverCLIProxyAPIArtifactsTransaction( stateRoot: URL?, fileManager: FileManager = .default, + recoverReplacementConfiguration: ((String) -> Bool?)? = nil, recoverRemovalConfiguration: (() -> Bool)? = nil) -> Bool { let manifestURL = self.cliProxyAPIArtifactsTransactionURL( @@ -531,7 +544,30 @@ public enum CostUsageCacheLocations { let didCommit = manifest.forceRollback != true && self.cliProxyAPIConfigurationGeneration(stateRoot: stateRoot, fileManager: fileManager) == manifest.expectedGeneration - if didCommit, manifest.replacementCredentialsStored == false { + if didCommit, + manifest.replacementCredentialsStored == false, + let fingerprint = manifest.replacementCredentialFingerprint + { + let recoverReplacementConfiguration = recoverReplacementConfiguration ?? { + CLIProxyAPIConnectionSettingsStore.replacementCredentialMatches(fingerprint: $0) + } + guard let replacementCredentialsStored = recoverReplacementConfiguration(fingerprint) else { + return false + } + if replacementCredentialsStored { + guard self.markCLIProxyAPIArtifactsReplacementCredentialsStored( + update, + fileManager: fileManager) + else { return false } + } else { + guard self.setCLIProxyAPIExplicitlyDisconnected( + manifest.disconnectedStateAfterRollback ?? true, + stateRoot: stateRoot, + fileManager: fileManager) + else { return false } + return self.restoreCLIProxyAPIArtifactsUpdate(update, fileManager: fileManager) + } + } else if didCommit, manifest.replacementCredentialsStored == false { guard self.setCLIProxyAPIExplicitlyDisconnected( manifest.disconnectedStateAfterRollback ?? true, stateRoot: stateRoot, @@ -818,7 +854,9 @@ public enum CostUsageCacheLocations { } return descriptor } +} +extension CostUsageCacheLocations { private static func releaseCLIProxyAPILock(_ descriptor: Int32) { _ = flock(descriptor, LOCK_UN) close(descriptor) diff --git a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift index b4b5e63a66..047719eee1 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift @@ -156,6 +156,52 @@ struct CLIProxyAPIAttributionResolverTests { .modelProvider, ]) } +} + +extension CLIProxyAPIAttributionResolverTests { + @Test + func `overflowing token totals reject telemetry instead of trapping`() { + let timestamp = Date(timeIntervalSince1970: 1_784_179_200) + let observations = [ + CLIProxyAPIAttributionResolver.Observation( + sessionID: "session-1", + model: "gpt-5.5", + timestamp: timestamp), + ] + let request = CLIProxyAPIAttributionResolver.Request( + model: "gpt-5.5", + modelProvider: .openAI, + sessionID: "session-1", + timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), + tokens: .init(input: Int.max - 1, cacheRead: 0, cacheCreate: 0, output: 20)) + let telemetryTotalOverflow = CLIProxyAPIAttributionResolver( + observations: observations, + usageRecords: [ + CLIProxyAPIUsageRecord( + timestamp: timestamp, + provider: "codex", + model: "gpt-5.5", + alias: "gpt-5.5", + endpoint: "POST /v1/messages", + authType: "oauth", + requestID: "request-overflow", + tokens: .init(input: Int.max, output: 20, cached: 1, total: Int.max)), + ]) + + #expect(telemetryTotalOverflow.attributions(for: [request]).first?.upstream == nil) + + let transcriptTotalOverflow = CLIProxyAPIAttributionResolver( + observations: observations, + usageRecords: [Self.record(timestamp: timestamp, provider: "codex", authType: "oauth")]) + let overflowingRequest = CLIProxyAPIAttributionResolver.Request( + model: "gpt-5.5", + modelProvider: .openAI, + sessionID: "session-1", + timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), + tokens: .init(input: Int.max, cacheRead: 1, cacheCreate: 0, output: 20)) + + #expect(transcriptTotalOverflow.attributions(for: [overflowingRequest]).first?.upstream == nil) + } @Test func `dated request log outranks an undated log for telemetry correlation`() { diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index 99bbe814f9..aab3bca20a 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -1395,12 +1395,20 @@ struct CLIProxyAPITransactionRecoveryTests { fileManager: fileManager, disconnectedStateAfterCommit: false, disconnectedStateAfterRollback: false, + replacementCredentialFingerprint: "replacement-fingerprint", replacementCredentialsStored: false)) #expect(CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate( generationUpdate, fileManager: fileManager)) - try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock(stateRoot: root, fileManager: fileManager) {} + try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( + stateRoot: root, + fileManager: fileManager, + recoverReplacementConfiguration: { fingerprint in + #expect(fingerprint == "replacement-fingerprint") + return false + }, + operation: {}) #expect(fileManager.fileExists(atPath: usageFile.path)) #expect(!CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( @@ -1410,6 +1418,49 @@ struct CLIProxyAPITransactionRecoveryTests { #expect(artifactsUpdate.manifestURL.map { !fileManager.fileExists(atPath: $0.path) } == true) } + @Test + func `interrupted replacement verifies stored credentials before discarding staged artifacts`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-replacement-after-store-\(UUID().uuidString)", isDirectory: true) + let costUsage = root.appendingPathComponent("cost-usage", isDirectory: true) + let usageFile = costUsage.appendingPathComponent(CostUsageCacheLocations.cliProxyAPIUsageFileName) + try fileManager.createDirectory(at: costUsage, withIntermediateDirectories: true) + try Data("telemetry".utf8).write(to: usageFile) + defer { try? fileManager.removeItem(at: root) } + + let generationUpdate = try #require(CostUsageCacheLocations + .prepareCLIProxyAPIConfigurationGenerationUpdate(stateRoot: root, fileManager: fileManager)) + let artifactsUpdate = try #require(CostUsageCacheLocations.prepareCLIProxyAPIArtifactsUpdate( + in: [costUsage], + stateRoot: root, + expectedGeneration: generationUpdate.generation, + fileManager: fileManager, + disconnectedStateAfterCommit: false, + disconnectedStateAfterRollback: false, + replacementCredentialFingerprint: "replacement-fingerprint", + replacementCredentialsStored: false)) + #expect(CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate( + generationUpdate, + fileManager: fileManager)) + + try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( + stateRoot: root, + fileManager: fileManager, + recoverReplacementConfiguration: { fingerprint in + #expect(fingerprint == "replacement-fingerprint") + return true + }, + operation: {}) + + #expect(!fileManager.fileExists(atPath: usageFile.path)) + #expect(!CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( + stateRoot: root, + fileManager: fileManager)) + #expect(artifactsUpdate.moves.allSatisfy { !fileManager.fileExists(atPath: $0.stagedURL.path) }) + #expect(artifactsUpdate.manifestURL.map { !fileManager.fileExists(atPath: $0.path) } == true) + } + @Test func `interrupted removal before isolation restores telemetry and connection state`() throws { let fileManager = FileManager.default From ea9bf8d046138e13fa3a160b1c3a0cbcfc64abd5 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 24 Aug 2026 07:59:34 -0700 Subject: [PATCH 131/183] Fix pre-credential rollback recovery --- .../CLIProxyAPIUsageTelemetry.swift | 51 +++++++++++-------- .../CostUsageCacheLocations.swift | 3 +- ...LIProxyAPIPreCredentialRollbackTests.swift | 47 +++++++++++++++++ .../CLIProxyAPIUsageCacheTests.swift | 5 ++ 4 files changed, 83 insertions(+), 23 deletions(-) create mode 100644 Tests/CodexBarTests/CLIProxyAPIPreCredentialRollbackTests.swift diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 6eb36e3cbc..8469baee01 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -861,19 +861,22 @@ public enum CLIProxyAPIConnectionSettingsStore { artifactsUpdate = update } - func rollback() { + func rollback(credentialsMayHaveChanged: Bool) { if let artifactsUpdate { guard CostUsageCacheLocations.markCLIProxyAPIArtifactsUpdateForRollback( artifactsUpdate, + rollbackCredentialsRestored: !credentialsMayHaveChanged, fileManager: fileManager) else { return } } - guard operations.restore(storedSettings) else { return } - if let artifactsUpdate { - guard CostUsageCacheLocations.markCLIProxyAPIArtifactsRollbackCredentialsRestored( - artifactsUpdate, - fileManager: fileManager) - else { return } + if credentialsMayHaveChanged { + guard operations.restore(storedSettings) else { return } + if let artifactsUpdate { + guard CostUsageCacheLocations.markCLIProxyAPIArtifactsRollbackCredentialsRestored( + artifactsUpdate, + fileManager: fileManager) + else { return } + } } guard operations.setDisconnectedState(wasDisconnected) else { return } if let artifactsUpdate { @@ -887,14 +890,14 @@ public enum CLIProxyAPIConnectionSettingsStore { generationUpdate, fileManager: fileManager) else { - rollback() + rollback(credentialsMayHaveChanged: false) return false } // Publish the telemetry invalidation before replacing credentials. If the process exits // during the Keychain write, recovery will discard the staged artifacts instead of exposing // telemetry collected under the previous credentials with the replacement configuration. guard operations.store(settings) else { - rollback() + rollback(credentialsMayHaveChanged: true) return false } if let artifactsUpdate, @@ -902,11 +905,11 @@ public enum CLIProxyAPIConnectionSettingsStore { artifactsUpdate, fileManager: fileManager) { - rollback() + rollback(credentialsMayHaveChanged: true) return false } guard operations.setDisconnectedState(false) else { - rollback() + rollback(credentialsMayHaveChanged: true) return false } if let artifactsUpdate { @@ -1011,16 +1014,20 @@ public enum CLIProxyAPIConnectionSettingsStore { removalCredentialsCleared: false) else { return .configurationRemovalFailed } - func rollback() { + func rollback(credentialsMayHaveChanged: Bool) { guard CostUsageCacheLocations.markCLIProxyAPIArtifactsUpdateForRollback( artifactsUpdate, - fileManager: fileManager), - operations.restore(snapshot.storedSettings), - CostUsageCacheLocations.markCLIProxyAPIArtifactsRollbackCredentialsRestored( - artifactsUpdate, - fileManager: fileManager), - operations.setDisconnectedState(snapshot.wasDisconnected) + rollbackCredentialsRestored: !credentialsMayHaveChanged, + fileManager: fileManager) else { return } + if credentialsMayHaveChanged { + guard operations.restore(snapshot.storedSettings), + CostUsageCacheLocations.markCLIProxyAPIArtifactsRollbackCredentialsRestored( + artifactsUpdate, + fileManager: fileManager) + else { return } + } + guard operations.setDisconnectedState(snapshot.wasDisconnected) else { return } _ = CostUsageCacheLocations.restoreCLIProxyAPIArtifactsUpdate( artifactsUpdate, fileManager: fileManager) @@ -1030,7 +1037,7 @@ public enum CLIProxyAPIConnectionSettingsStore { generationUpdate, fileManager: fileManager) else { - rollback() + rollback(credentialsMayHaveChanged: false) return .configurationRemovalFailed } guard operations.setDisconnectedState(true), @@ -1038,20 +1045,20 @@ public enum CLIProxyAPIConnectionSettingsStore { artifactsUpdate, fileManager: fileManager) else { - rollback() + rollback(credentialsMayHaveChanged: false) return .configurationRemovalFailed } // Isolation is transaction-owned and durable before Keychain deletion. Recovery can now finish // deletion without confusing a disconnect marker that predated this removal. guard operations.clearConfiguration() else { - rollback() + rollback(credentialsMayHaveChanged: true) return .configurationRemovalFailed } guard CostUsageCacheLocations.markCLIProxyAPIArtifactsRemovalCredentialsCleared( artifactsUpdate, fileManager: fileManager) else { - rollback() + rollback(credentialsMayHaveChanged: true) return .configurationRemovalFailed } return CostUsageCacheLocations.discardCLIProxyAPIArtifactsUpdate( diff --git a/Sources/CodexBarCore/CostUsageCacheLocations.swift b/Sources/CodexBarCore/CostUsageCacheLocations.swift index cebaaa5af5..7a9b1b6245 100644 --- a/Sources/CodexBarCore/CostUsageCacheLocations.swift +++ b/Sources/CodexBarCore/CostUsageCacheLocations.swift @@ -321,6 +321,7 @@ public enum CostUsageCacheLocations { @discardableResult static func markCLIProxyAPIArtifactsUpdateForRollback( _ update: CLIProxyAPIArtifactsUpdate, + rollbackCredentialsRestored: Bool = false, fileManager: FileManager) -> Bool { guard let manifestURL = update.manifestURL else { return true } @@ -336,7 +337,7 @@ public enum CostUsageCacheLocations { replacementCredentialFingerprint: manifest.replacementCredentialFingerprint, replacementCredentialsStored: manifest.replacementCredentialsStored, forceRollback: true, - rollbackCredentialsRestored: manifest.rollbackCredentialsRestored, + rollbackCredentialsRestored: rollbackCredentialsRestored || manifest.rollbackCredentialsRestored == true, removalIsolationPublished: manifest.removalIsolationPublished, removalCredentialsCleared: manifest.removalCredentialsCleared) do { diff --git a/Tests/CodexBarTests/CLIProxyAPIPreCredentialRollbackTests.swift b/Tests/CodexBarTests/CLIProxyAPIPreCredentialRollbackTests.swift new file mode 100644 index 0000000000..d1d560884b --- /dev/null +++ b/Tests/CodexBarTests/CLIProxyAPIPreCredentialRollbackTests.swift @@ -0,0 +1,47 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CLIProxyAPIPreCredentialRollbackTests { + @Test + func `pre credential rollback restores staged artifacts without isolating the prior configuration`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-pre-credential-rollback-\(UUID().uuidString)", isDirectory: true) + let costUsage = root.appendingPathComponent("cost-usage", isDirectory: true) + let usageFile = costUsage.appendingPathComponent(CostUsageCacheLocations.cliProxyAPIUsageFileName) + try fileManager.createDirectory(at: costUsage, withIntermediateDirectories: true) + try Data("telemetry".utf8).write(to: usageFile) + defer { try? fileManager.removeItem(at: root) } + + let generationUpdate = try #require(CostUsageCacheLocations + .prepareCLIProxyAPIConfigurationGenerationUpdate(stateRoot: root, fileManager: fileManager)) + let artifactsUpdate = try #require(CostUsageCacheLocations.prepareCLIProxyAPIArtifactsUpdate( + in: [costUsage], + stateRoot: root, + expectedGeneration: generationUpdate.generation, + fileManager: fileManager, + disconnectedStateAfterRollback: false, + prepareState: { + CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected( + true, + stateRoot: root, + fileManager: fileManager) + })) + #expect(CostUsageCacheLocations.markCLIProxyAPIArtifactsUpdateForRollback( + artifactsUpdate, + rollbackCredentialsRestored: true, + fileManager: fileManager)) + + try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock(stateRoot: root, fileManager: fileManager) {} + + #expect(fileManager.fileExists(atPath: usageFile.path)) + #expect(!CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( + stateRoot: root, + fileManager: fileManager)) + #expect(artifactsUpdate.manifestURL.map { !fileManager.fileExists(atPath: $0.path) } == true) + CostUsageCacheLocations.discardCLIProxyAPIConfigurationGenerationUpdate( + generationUpdate, + fileManager: fileManager) + } +} diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index aab3bca20a..5721c4dd52 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -388,7 +388,9 @@ struct CLIProxyAPIUsageCacheTests { stateRoot: root, fileManager: fileManager) != nil) } +} +extension CLIProxyAPIUsageCacheTests { @Test func `generation publication failure restores replacement credentials state and telemetry`() throws { let fileManager = FileManager.default @@ -408,6 +410,7 @@ struct CLIProxyAPIUsageCacheTests { let storedSettings = LockIsolated(existing) let disconnected = LockIsolated(true) let didStore = LockIsolated(false) + let didRestore = LockIsolated(false) let saved = CLIProxyAPIConnectionSettingsStore.saveSerialized( replacement, @@ -427,6 +430,7 @@ struct CLIProxyAPIUsageCacheTests { return true }, restore: { snapshot in + didRestore.setValue(true) guard case let .found(settings) = snapshot else { return false } storedSettings.setValue(settings) return true @@ -434,6 +438,7 @@ struct CLIProxyAPIUsageCacheTests { #expect(!saved) #expect(!didStore.value) + #expect(!didRestore.value) #expect(storedSettings.value == existing) #expect(disconnected.value) #expect(fileManager.fileExists(atPath: usageFile.path)) From a5b51c118211b0c5cfcbcdfe76f4255502cdc905 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 24 Aug 2026 20:59:54 -0700 Subject: [PATCH 132/183] Preserve proxy merge request metadata --- Sources/CodexBarCore/CostUsageModels.swift | 44 ++++++++++++++++++- .../CostUsageDailyReportMergeTests.swift | 42 ++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index 3af4da5045..abf16dc080 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -880,11 +880,21 @@ extension CostUsageDailyReport { var sawCacheCreationTokens = false var outputTokens: Int = 0 var sawOutputTokens = false + var reasoningTokens: Int = 0 + var sawReasoningTokens = false var totalTokens: Int = 0 var sawTotalTokens = false var derivedTotalTokensWithoutExplicitTotal: Int = 0 + var requestCount: Int = 0 + var sawRequestCount = false var costUSD: Double = 0 var sawCost = false + var unpricedRequestCount: Int = 0 + var sawUnpricedRequestCount = false + var unmeteredRequestCount: Int = 0 + var sawUnmeteredRequestCount = false + var estimatedRequestCount: Int = 0 + var sawEstimatedRequestCount = false var modelsUsed: Set = [] var breakdowns: [BreakdownKey: BreakdownAccumulator] = [:] @@ -909,16 +919,36 @@ extension CostUsageDailyReport { self.outputTokens += outputTokens self.sawOutputTokens = true } + if let reasoningTokens = entry.reasoningTokens { + self.reasoningTokens += reasoningTokens + self.sawReasoningTokens = true + } if let totalTokens = entry.totalTokens { self.totalTokens += totalTokens self.sawTotalTokens = true } else if entryDerivedTotalTokens > 0 { self.derivedTotalTokensWithoutExplicitTotal += entryDerivedTotalTokens } + if let requestCount = entry.requestCount { + self.requestCount += requestCount + self.sawRequestCount = true + } if let costUSD = entry.costUSD { self.costUSD += costUSD self.sawCost = true } + if let unpricedRequestCount = entry.unpricedRequestCount { + self.unpricedRequestCount += unpricedRequestCount + self.sawUnpricedRequestCount = true + } + if let unmeteredRequestCount = entry.unmeteredRequestCount { + self.unmeteredRequestCount += unmeteredRequestCount + self.sawUnmeteredRequestCount = true + } + if let estimatedRequestCount = entry.estimatedRequestCount { + self.estimatedRequestCount += estimatedRequestCount + self.sawEstimatedRequestCount = true + } if let modelsUsed = entry.modelsUsed { self.modelsUsed.formUnion(modelsUsed) } @@ -962,10 +992,15 @@ extension CostUsageDailyReport { outputTokens: self.sawOutputTokens ? self.outputTokens : nil, cacheReadTokens: self.sawCacheReadTokens ? self.cacheReadTokens : nil, cacheCreationTokens: self.sawCacheCreationTokens ? self.cacheCreationTokens : nil, + reasoningTokens: self.sawReasoningTokens ? self.reasoningTokens : nil, totalTokens: totalTokens, + requestCount: self.sawRequestCount ? self.requestCount : nil, costUSD: self.sawCost ? self.costUSD : nil, modelsUsed: modelsUsed, - modelBreakdowns: modelBreakdowns) + modelBreakdowns: modelBreakdowns, + unpricedRequestCount: self.sawUnpricedRequestCount ? self.unpricedRequestCount : nil, + unmeteredRequestCount: self.sawUnmeteredRequestCount ? self.unmeteredRequestCount : nil, + estimatedRequestCount: self.sawEstimatedRequestCount ? self.estimatedRequestCount : nil) } } @@ -1006,6 +1041,8 @@ extension CostUsageDailyReport { var sawTotalCacheReadTokens = false var totalCacheCreationTokens = 0 var sawTotalCacheCreationTokens = false + var totalReasoningTokens = 0 + var sawTotalReasoningTokens = false var totalTokens = 0 var sawTotalTokens = false var totalCostUSD = 0.0 @@ -1028,6 +1065,10 @@ extension CostUsageDailyReport { totalCacheCreationTokens += cacheCreationTokens sawTotalCacheCreationTokens = true } + if let reasoningTokens = entry.reasoningTokens { + totalReasoningTokens += reasoningTokens + sawTotalReasoningTokens = true + } if let entryTotalTokens = entry.totalTokens { totalTokens += entryTotalTokens sawTotalTokens = true @@ -1043,6 +1084,7 @@ extension CostUsageDailyReport { totalOutputTokens: sawTotalOutputTokens ? totalOutputTokens : nil, cacheReadTokens: sawTotalCacheReadTokens ? totalCacheReadTokens : nil, cacheCreationTokens: sawTotalCacheCreationTokens ? totalCacheCreationTokens : nil, + reasoningTokens: sawTotalReasoningTokens ? totalReasoningTokens : nil, totalTokens: sawTotalTokens ? totalTokens : nil, totalCostUSD: sawTotalCostUSD ? totalCostUSD : nil) } diff --git a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift index 5ab569a8c9..fcd6ec6564 100644 --- a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift +++ b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift @@ -136,6 +136,48 @@ struct CostUsageDailyReportMergeTests { #expect(abs((merged.summary?.totalCostUSD ?? 0) - 2.0) < 0.000001) } + @Test + func `merged report preserves native request and coverage metadata with proxy usage`() throws { + let native = CostUsageDailyReport( + data: [ + CostUsageDailyReport.Entry( + date: "2026-04-04", + inputTokens: 100, + outputTokens: 20, + reasoningTokens: 12, + totalTokens: 120, + requestCount: 7, + costUSD: 1.25, + modelsUsed: ["gpt-5.4"], + modelBreakdowns: nil, + unpricedRequestCount: 2, + unmeteredRequestCount: 1, + estimatedRequestCount: 3), + ], + summary: nil) + let proxy = CostUsageDailyReport( + data: [ + CostUsageDailyReport.Entry( + date: "2026-04-04", + inputTokens: 50, + outputTokens: 10, + totalTokens: 60, + costUSD: 0.75, + modelsUsed: ["gpt-5.4"], + modelBreakdowns: nil), + ], + summary: nil) + + let merged = native.merged(with: proxy) + let entry = try #require(merged.data.first) + #expect(entry.requestCount == 7) + #expect(entry.reasoningTokens == 12) + #expect(entry.unpricedRequestCount == 2) + #expect(entry.unmeteredRequestCount == 1) + #expect(entry.estimatedRequestCount == 3) + #expect(merged.summary?.reasoningTokens == 12) + } + @Test func `merged report unions days and orders model breakdowns deterministically`() { let first = CostUsageDailyReport( From e1323a6f27af2c830a0512f38dbbe4806222d006 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 24 Aug 2026 23:01:31 -0700 Subject: [PATCH 133/183] Classify Google models before Claude --- .../Generated/CodexParserHash.generated.swift | 2 +- .../CostUsagePricing+ModelProvider.swift | 22 +++++++++---------- .../CodexBarTests/ModelsDevPricingTests.swift | 21 ++++++++++++++++++ 3 files changed, 33 insertions(+), 12 deletions(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 34d52846b9..a15016c67d 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 = "b83b577e784582ae" + static let value = "0c2b62e29e49432b" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ModelProvider.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ModelProvider.swift index 687eba810f..3621fd17fa 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ModelProvider.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ModelProvider.swift @@ -14,6 +14,17 @@ extension CostUsagePricing { return .openAI } + let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.lowercased().hasPrefix("gemini-") + || modelsDevCatalog?.pricing(providerID: "google", modelID: trimmed) != nil + || ModelsDevPricingPipeline.lookup( + providerID: "google", + modelID: trimmed, + cacheRoot: modelsDevCacheRoot) != nil + { + return .google + } + if self.claudeCostUSD( model: model, inputTokens: 0, @@ -26,17 +37,6 @@ extension CostUsagePricing { return .anthropic } - let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.lowercased().hasPrefix("gemini-") - || modelsDevCatalog?.pricing(providerID: "google", modelID: trimmed) != nil - || ModelsDevPricingPipeline.lookup( - providerID: "google", - modelID: trimmed, - cacheRoot: modelsDevCacheRoot) != nil - { - return .google - } - return .unknown } } diff --git a/Tests/CodexBarTests/ModelsDevPricingTests.swift b/Tests/CodexBarTests/ModelsDevPricingTests.swift index 9d9c156dec..05c6c367c6 100644 --- a/Tests/CodexBarTests/ModelsDevPricingTests.swift +++ b/Tests/CodexBarTests/ModelsDevPricingTests.swift @@ -52,6 +52,27 @@ struct ModelsDevPricingTests { #expect(vertex.pricing.inputCostPerToken == 3.1 / 1_000_000.0) } + @Test + func `classifies google catalog aliases before claude pricing`() throws { + let catalog = try Self.catalog(""" + { + "google": { + "id": "google", + "models": { + "proxy-gemini-alias": { + "id": "proxy-gemini-alias", + "cost": { "input": 2, "output": 10 } + } + } + } + } + """) + + #expect(CostUsagePricing.modelProvider( + for: "proxy-gemini-alias", + modelsDevCatalog: catalog) == .google) + } + @Test func `converts models dev per million token prices to per token prices`() throws { let pricing = try #require(try Self.fixtureCatalog().pricing( From 07fc8d878b785557bc2cbf67317bf8a4e536f739 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 25 Aug 2026 01:01:53 -0700 Subject: [PATCH 134/183] Guard proxy snapshot publication --- .../UsageStore+CodexCostCatchUp.swift | 7 +- ...Store+SpendDashboardCodexCostCatchUp.swift | 7 +- .../UsageStore+SpendDashboardTokenCost.swift | 5 ++ Sources/CodexBar/UsageStore+TokenCost.swift | 30 +++++++- .../CLIProxyAPIPublicationGuardTests.swift | 71 +++++++++++++++++++ 5 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 Tests/CodexBarTests/CLIProxyAPIPublicationGuardTests.swift diff --git a/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift b/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift index 2794820073..dabd71f143 100644 --- a/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift +++ b/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift @@ -8,6 +8,7 @@ private struct CodexCostCatchUpContext { let scopeSignature: String let providerConfigRevision: UInt64 let costUsageSettingsRevision: UInt64 + let cliProxyAPIAttributionGuard: CLIProxyAPIAttributionPublicationGuard } extension UsageStore { @@ -45,7 +46,8 @@ extension UsageStore { historyDays: self.settings.costUsageHistoryDays, scopeSignature: scopeSignature, providerConfigRevision: self.settings.providerConfigRevision(for: .codex), - costUsageSettingsRevision: self.settings.costUsageSettingsRevision) + costUsageSettingsRevision: self.settings.costUsageSettingsRevision, + cliProxyAPIAttributionGuard: self.cliProxyAPIAttributionPublicationGuard()) self.codexCostCatchUpToken = token self.codexCostCatchUpScopeSignature = scopeSignature self.codexCostCatchUpMode = mode @@ -287,6 +289,9 @@ extension UsageStore { && self.codexCostCatchUpToken == context.token && self.settings.providerConfigRevision(for: .codex) == context.providerConfigRevision && self.settings.costUsageSettingsRevision == context.costUsageSettingsRevision + && self.cliProxyAPIAttributionPublicationIsCurrent( + context.cliProxyAPIAttributionGuard, + for: .codex) && self.settings.costUsageHistoryDays == context.historyDays && self.settings.isCostUsageEffectivelyEnabled(for: .codex) && self.isEnabled(.codex) diff --git a/Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift b/Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift index 13bc2b7b8e..aa386db276 100644 --- a/Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift +++ b/Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift @@ -8,6 +8,7 @@ private struct SpendDashboardCodexCostCatchUpContext { let scopeSignature: String let providerConfigRevision: UInt64 let costUsageSettingsRevision: UInt64 + let cliProxyAPIAttributionGuard: CLIProxyAPIAttributionPublicationGuard } extension UsageStore { @@ -61,7 +62,8 @@ extension UsageStore { historyDays: historyDays, scopeSignature: scopeSignature, providerConfigRevision: self.settings.providerConfigRevision(for: .codex), - costUsageSettingsRevision: self.settings.costUsageSettingsRevision) + costUsageSettingsRevision: self.settings.costUsageSettingsRevision, + cliProxyAPIAttributionGuard: self.cliProxyAPIAttributionPublicationGuard()) self.spendDashboardCodexCostCatchUpToken = token self.spendDashboardCodexCostCatchUpScopeSignature = scopeSignature self.spendDashboardCodexCostCatchUpMode = mode @@ -261,6 +263,9 @@ extension UsageStore { && self.spendDashboardCodexCostCatchUpScopeSignature == context.scopeSignature && self.settings.providerConfigRevision(for: .codex) == context.providerConfigRevision && self.settings.costUsageSettingsRevision == context.costUsageSettingsRevision + && self.cliProxyAPIAttributionPublicationIsCurrent( + context.cliProxyAPIAttributionGuard, + for: .codex) && max(SpendDashboardSource.scanDays, self.settings.costUsageHistoryDays) == context.historyDays && self.settings.isCostUsageEffectivelyEnabled(for: .codex) && self.isEnabled(.codex) diff --git a/Sources/CodexBar/UsageStore+SpendDashboardTokenCost.swift b/Sources/CodexBar/UsageStore+SpendDashboardTokenCost.swift index 6b55bc227c..ad7df7c4b9 100644 --- a/Sources/CodexBar/UsageStore+SpendDashboardTokenCost.swift +++ b/Sources/CodexBar/UsageStore+SpendDashboardTokenCost.swift @@ -71,6 +71,7 @@ extension UsageStore { let costScopeSignature = self.spendDashboardTokenSnapshotScopeSignature(for: provider) let publicationRevision = self.providerPublicationRevision(for: provider) let providerConfigRevision = self.settings.providerConfigRevision(for: provider) + let cliProxyAPIAttributionGuard = self.cliProxyAPIAttributionPublicationGuard() self.lastSpendDashboardTokenFetchAt[provider.instanceID] = now self.lastSpendDashboardTokenFetchScope[provider.instanceID] = costScopeSignature self.spendDashboardTokenRefreshInFlight.insert(provider.instanceID) @@ -104,6 +105,7 @@ extension UsageStore { provider: provider, publicationRevision: publicationRevision, providerConfigRevision: providerConfigRevision, + cliProxyAPIAttributionGuard: cliProxyAPIAttributionGuard, costScopeSignature: costScopeSignature, fetchedCredentialScopeFingerprint: snapshot.credentialScopeFingerprint) else { @@ -125,6 +127,7 @@ extension UsageStore { provider: provider, publicationRevision: publicationRevision, providerConfigRevision: providerConfigRevision, + cliProxyAPIAttributionGuard: cliProxyAPIAttributionGuard, costScopeSignature: costScopeSignature) else { self.clearSpendDashboardTokenFetchMetadataIfMatching( @@ -185,11 +188,13 @@ extension UsageStore { provider: UsageProvider, publicationRevision: ProviderPublicationRevision, providerConfigRevision: UInt64, + cliProxyAPIAttributionGuard: CLIProxyAPIAttributionPublicationGuard, costScopeSignature: String, fetchedCredentialScopeFingerprint: String? = nil) -> Bool { guard self.providerPublicationRevisionIsCurrent(publicationRevision, for: provider), self.settings.providerConfigRevision(for: provider) == providerConfigRevision, + self.cliProxyAPIAttributionPublicationIsCurrent(cliProxyAPIAttributionGuard, for: provider), self.settings.costUsageEnabled, self.isEnabled(provider) else { diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 5790def2cd..122f9454e1 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -15,6 +15,13 @@ struct TokenRefreshPublicationGuard { let provider: UsageStore.ProviderPublicationRevision let tokenSnapshot: UInt64 let providerConfig: UInt64 + let cliProxyAPIAttribution: CLIProxyAPIAttributionPublicationGuard +} + +struct CLIProxyAPIAttributionPublicationGuard: Sendable, Equatable { + let configurationGeneration: String? + let telemetryRevision: String? + let isIsolated: Bool } struct TokenSnapshotPublication: Sendable, Equatable { @@ -128,7 +135,25 @@ extension UsageStore { TokenRefreshPublicationGuard( provider: self.providerPublicationRevision(for: provider), tokenSnapshot: self.tokenSnapshotPublicationRevision(for: provider), - providerConfig: self.settings.providerConfigRevision(for: provider)) + providerConfig: self.settings.providerConfigRevision(for: provider), + cliProxyAPIAttribution: self.cliProxyAPIAttributionPublicationGuard()) + } + + func cliProxyAPIAttributionPublicationGuard() -> CLIProxyAPIAttributionPublicationGuard { + CLIProxyAPIAttributionPublicationGuard( + configurationGeneration: self.costUsageFetcher.cliProxyAPIConfigurationGeneration(), + telemetryRevision: self.costUsageFetcher.cliProxyAPIUsageTelemetryRevision(), + isIsolated: self.costUsageFetcher.cliProxyAPIAttributionIsIsolated()) + } + + func cliProxyAPIAttributionPublicationIsCurrent( + _ guardValue: CLIProxyAPIAttributionPublicationGuard, + for provider: UsageProvider) -> Bool + { + guard provider == .codex || provider == .claude else { return true } + return self.costUsageFetcher.cliProxyAPIConfigurationGeneration() == guardValue.configurationGeneration + && self.costUsageFetcher.cliProxyAPIUsageTelemetryRevision() == guardValue.telemetryRevision + && self.costUsageFetcher.cliProxyAPIAttributionIsIsolated() == guardValue.isIsolated } func publishTokenSnapshot(_ snapshot: CostUsageTokenSnapshot, for provider: UsageProvider) { @@ -445,6 +470,9 @@ extension UsageStore { guard self.providerPublicationRevisionIsCurrent(publicationGuard.provider, for: provider), self.tokenSnapshotPublicationRevision(for: provider) == publicationGuard.tokenSnapshot, self.settings.providerConfigRevision(for: provider) == publicationGuard.providerConfig, + self.cliProxyAPIAttributionPublicationIsCurrent( + publicationGuard.cliProxyAPIAttribution, + for: provider), self.settings.costUsageEnabled, self.isEnabled(provider), self.settings.costUsageHistoryDays == historyDays diff --git a/Tests/CodexBarTests/CLIProxyAPIPublicationGuardTests.swift b/Tests/CodexBarTests/CLIProxyAPIPublicationGuardTests.swift new file mode 100644 index 0000000000..b82612eeda --- /dev/null +++ b/Tests/CodexBarTests/CLIProxyAPIPublicationGuardTests.swift @@ -0,0 +1,71 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@MainActor +struct CLIProxyAPIPublicationGuardTests { + @Test + func `live refresh guard rejects cross process proxy state changes`() throws { + let settings = testSettingsStore(suiteName: "CLIProxyAPIPublicationGuardTests-\(UUID().uuidString)") + settings.costUsageEnabled = true + let metadata = try #require(ProviderRegistry.shared.metadata[.codex]) + settings.setProviderEnabled(provider: .codex, metadata: metadata, enabled: true) + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cliproxy-live-publication-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + costUsageFetcher: CostUsageFetcher(cacheRoot: root), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + let historyDays = settings.costUsageHistoryDays + let scopeSignature = store.tokenSnapshotScopeSignature(for: .codex) + + let generationGuard = store.tokenRefreshPublicationGuard(for: .codex) + let artifactDirectory = root.appendingPathComponent("cost-usage", isDirectory: true) + try FileManager.default.createDirectory(at: artifactDirectory, withIntermediateDirectories: true) + try Data("cache".utf8).write(to: artifactDirectory.appendingPathComponent("codex-v11.json")) + let clearResult = CostUsageCacheLocations.clearAllCostUsageCaches( + in: [artifactDirectory], + stateRoot: root) + #expect(clearResult.errorDescription == nil) + #expect(!store.tokenRefreshPublicationIsCurrent( + provider: .codex, + publicationGuard: generationGuard, + historyDays: historyDays, + costScopeSignature: scopeSignature)) + + let telemetryGuard = store.tokenRefreshPublicationGuard(for: .codex) + let now = Date(timeIntervalSince1970: 1_775_000_000) + #expect(CLIProxyAPIUsageCacheIO.merge( + [ + CLIProxyAPIUsageRecord( + timestamp: now, + provider: "codex", + model: "gpt-5.4", + alias: "gpt-5.4", + endpoint: "POST /v1/messages", + authType: "oauth", + requestID: "live-publication-race", + tokens: .init(input: 10, output: 20, total: 30)), + ], + cacheRoot: root, + now: now) == 1) + #expect(!store.tokenRefreshPublicationIsCurrent( + provider: .codex, + publicationGuard: telemetryGuard, + historyDays: historyDays, + costScopeSignature: scopeSignature)) + + let isolationGuard = store.tokenRefreshPublicationGuard(for: .codex) + #expect(CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected(true, stateRoot: root)) + #expect(!store.tokenRefreshPublicationIsCurrent( + provider: .codex, + publicationGuard: isolationGuard, + historyDays: historyDays, + costScopeSignature: scopeSignature)) + } +} From aa43db40938f2036ff76f4c445cc73bb643f62ce Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 25 Aug 2026 06:54:58 -0700 Subject: [PATCH 135/183] Guard dashboard proxy publication --- .../CodexBar/SpendDashboardController.swift | 38 ++++++++++-- .../CodexBarTests/CostUsagePricingTests.swift | 27 +++++--- .../ProviderArchitectureGatekeeperTests.swift | 34 +++++------ .../SpendDashboardCodexProxySourceTests.swift | 61 +++++++++++++++++++ 4 files changed, 129 insertions(+), 31 deletions(-) diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index a6922fdfbe..8918666001 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -173,6 +173,7 @@ enum SpendDashboardSource { // swiftlint:disable:this type_body_length -> CostUsageTokenActivityCache? typealias CodexProxySnapshotLoader = @Sendable (CodexProxySpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot + typealias CodexProxyAttributionGuardLoader = @Sendable () -> CLIProxyAPIAttributionPublicationGuard typealias CachedCodexSnapshotLoader = @Sendable (CodexSpendSnapshotLoadContext) async -> CostUsageTokenSnapshot? typealias CodexCacheRootResolver = @Sendable (CodexSpendScanRequest) -> URL @@ -377,7 +378,8 @@ enum SpendDashboardSource { // swiftlint:disable:this type_body_length cacheRootResolver: { self.codexCacheRoot(for: $0) }, codexSnapshotLoader: { context in try await self.loadCodexSnapshot(context) }, codexActivityLoader: { context in await self.loadCodexActivity(context) }, - codexProxySnapshotLoader: codexProxySnapshotLoader) + codexProxySnapshotLoader: codexProxySnapshotLoader, + codexProxyAttributionGuardLoader: self.loadCodexProxyAttributionGuard) } static func load( @@ -488,14 +490,21 @@ enum SpendDashboardSource { // swiftlint:disable:this type_body_length static func load( _ request: SpendDashboardLoadRequest, codexSnapshotLoader: @escaping CodexSnapshotLoader, - codexProxySnapshotLoader: CodexProxySnapshotLoader?) async -> SpendDashboardLoadResult + codexProxySnapshotLoader: CodexProxySnapshotLoader?, + codexProxyAttributionGuardLoader: @escaping CodexProxyAttributionGuardLoader = { + CLIProxyAPIAttributionPublicationGuard( + configurationGeneration: nil, + telemetryRevision: nil, + isIsolated: false) + }) async -> SpendDashboardLoadResult { await self.load( request, cacheRootResolver: { self.codexCacheRoot(for: $0) }, codexSnapshotLoader: codexSnapshotLoader, codexActivityLoader: { _ in nil }, - codexProxySnapshotLoader: codexProxySnapshotLoader) + codexProxySnapshotLoader: codexProxySnapshotLoader, + codexProxyAttributionGuardLoader: codexProxyAttributionGuardLoader) } private static func load( @@ -503,7 +512,13 @@ enum SpendDashboardSource { // swiftlint:disable:this type_body_length cacheRootResolver: @escaping CodexCacheRootResolver, codexSnapshotLoader: @escaping CodexSnapshotLoader, codexActivityLoader: @escaping CodexActivityLoader, - codexProxySnapshotLoader: CodexProxySnapshotLoader?) async -> SpendDashboardLoadResult + codexProxySnapshotLoader: CodexProxySnapshotLoader?, + codexProxyAttributionGuardLoader: @escaping CodexProxyAttributionGuardLoader = { + CLIProxyAPIAttributionPublicationGuard( + configurationGeneration: nil, + telemetryRevision: nil, + isIsolated: false) + }) async -> SpendDashboardLoadResult { var inputs = request.capturedInputs var failedSourceIDs = request.unavailableSourceIDs @@ -601,6 +616,7 @@ enum SpendDashboardSource { // swiftlint:disable:this type_body_length let codexProxySnapshotLoader { do { + let attributionGuard = codexProxyAttributionGuardLoader() let snapshot = try await codexProxySnapshotLoader(CodexProxySpendSnapshotLoadContext( now: request.now, force: request.force, @@ -608,7 +624,11 @@ enum SpendDashboardSource { // swiftlint:disable:this type_body_length refreshPricingInBackground: false, calendar: request.configuration.bucketCalendar)) try Task.checkCancellation() - if !snapshot.daily.isEmpty { + if codexProxyAttributionGuardLoader() != attributionGuard { + inputs.removeAll { $0.id == Self.codexProxySourceID } + failedSourceIDs.insert(Self.codexProxySourceID) + invalidatedSourceIDs.insert(Self.codexProxySourceID) + } else if !snapshot.daily.isEmpty { // Provider-specific by design: proxy-attributed Claude rows are published in the Codex family. let providerName = ProviderDescriptorRegistry.descriptor(for: .codex).metadata.displayName inputs.append(SpendDashboardModel.ProviderInput( @@ -690,6 +710,14 @@ enum SpendDashboardSource { // swiftlint:disable:this type_body_length refreshPricingInBackground: context.refreshPricingInBackground) } + private static func loadCodexProxyAttributionGuard() -> CLIProxyAPIAttributionPublicationGuard { + let fetcher = CostUsageFetcher() + return CLIProxyAPIAttributionPublicationGuard( + configurationGeneration: fetcher.cliProxyAPIConfigurationGeneration(), + telemetryRevision: fetcher.cliProxyAPIUsageTelemetryRevision(), + isIsolated: fetcher.cliProxyAPIAttributionIsIsolated()) + } + static func shouldLoadCodexProxy(providerIDs: [String]) -> Bool { // Provider-specific by design: proxy attribution can affect both the Codex and Claude spend projections. providerIDs.contains(UsageProvider.codex.rawValue) diff --git a/Tests/CodexBarTests/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift index 84dd77ca2a..0ab0d09402 100644 --- a/Tests/CodexBarTests/CostUsagePricingTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingTests.swift @@ -400,22 +400,26 @@ struct CostUsagePricingTests { } @Test - func `codex API fast cost matches brief gpt56 scenarios`() { + func `codex API fast cost matches brief gpt56 scenarios`() throws { + let root = try Self.cacheRoot() let sol = CostUsagePricing.codexPriorityCostUSD( model: "gpt-5.6-sol", inputTokens: 100_000, cachedInputTokens: 20000, - outputTokens: 20000) + outputTokens: 20000, + modelsDevCacheRoot: root) let terra = CostUsagePricing.codexPriorityCostUSD( model: "gpt-5.6-terra", inputTokens: 100_000, cachedInputTokens: 20000, - outputTokens: 20000) + outputTokens: 20000, + modelsDevCacheRoot: root) let luna = CostUsagePricing.codexPriorityCostUSD( model: "gpt-5.6-luna", inputTokens: 100_000, cachedInputTokens: 20000, - outputTokens: 20000) + outputTokens: 20000, + modelsDevCacheRoot: root) // Public API Fast rates are 2x Standard for GPT-5.6. let expectedSol = 2.02 @@ -427,31 +431,36 @@ struct CostUsagePricingTests { } @Test - func `codex priority cost multiplies standard cache write rates`() { + func `codex priority cost multiplies standard cache write rates`() throws { + let root = try Self.cacheRoot() let sol = CostUsagePricing.codexPriorityCostUSD( model: "gpt-5.6-sol", inputTokens: 100, cachedInputTokens: 10, cacheWriteInputTokens: 20, - outputTokens: 5) + outputTokens: 5, + modelsDevCacheRoot: root) let terra = CostUsagePricing.codexPriorityCostUSD( model: "gpt-5.6-terra", inputTokens: 100, cachedInputTokens: 10, cacheWriteInputTokens: 20, - outputTokens: 5) + outputTokens: 5, + modelsDevCacheRoot: root) let luna = CostUsagePricing.codexPriorityCostUSD( model: "gpt-5.6-luna", inputTokens: 100, cachedInputTokens: 10, cacheWriteInputTokens: 20, - outputTokens: 5) + outputTokens: 5, + modelsDevCacheRoot: root) let modelWithoutCacheWriteSupport = CostUsagePricing.codexPriorityCostUSD( model: "gpt-5.5", inputTokens: 100, cachedInputTokens: 10, cacheWriteInputTokens: 20, - outputTokens: 5) + outputTokens: 5, + modelsDevCacheRoot: root) let solInput = 70.0 * 5e-6 let solCached = 10.0 * 5e-7 diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index aaee268049..a32df54fbf 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -897,55 +897,55 @@ struct ProviderArchitectureGatekeeperTests { reason: "This observation touchpoint reads a fixed provider field so UI invalidation tracks that setting."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 451, + line: 453, 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: 453, + line: 455, anchor: "modelProviderName: ProviderDescriptorRegistry.descriptor(for: .codex).metadata.displayName,", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 555, + line: 570, 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: 558, + line: 573, 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: 616, + line: 636, 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: 733, + line: 761, 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: 1748, + line: 1776, 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: 1777, + line: 1805, 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: 1794, + line: 1822, anchor: "if sourceID.hasPrefix(\"codex:\") { return .codex }", expectedProviderIDs: ["codex"], reason: "This publication projection maps stable Codex account source IDs back to their provider family."), @@ -2331,7 +2331,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 722, + line: 750, anchor: "(providers.contains(.codex) && settings.codexLocalSessionCostLedgerEnabled)", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2339,7 +2339,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct preserves the provider-owned local ledger when global scanning is off."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 196, + line: 197, anchor: "let codexSources = providers.contains(.codex)", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2347,7 +2347,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: 263, + line: 264, anchor: "let providerBaselines = initialProviders.filter { $0 != .codex }.map { provider in", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2355,7 +2355,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: 291, + line: 292, anchor: "let codexSources = providers.contains(.codex)", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2363,7 +2363,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: 314, + line: 315, anchor: "for provider in providers where provider != .codex {", expectedProviderIDs: ["codex", "grok"], expectedReferenceCount: 7, @@ -2371,7 +2371,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: 784, + line: 812, anchor: "if providers.contains(.codex) {", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2379,7 +2379,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: 812, + line: 840, anchor: "if providers.contains(.codex) {", expectedProviderIDs: ["codex"], expectedReferenceCount: 3, @@ -2387,7 +2387,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: 1821, + line: 1849, anchor: "guard input.provider == .codex,", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, diff --git a/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift b/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift index 7f3eb1c71e..3121c1e6c5 100644 --- a/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift +++ b/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift @@ -80,6 +80,67 @@ struct SpendDashboardCodexProxySourceTests { #expect(result.inputs.map(\.id) == [SpendDashboardSource.codexProxySourceID]) } + @Test + func `proxy usage is invalidated when attribution boundaries change during load`() async { + let now = Date(timeIntervalSince1970: 1_784_179_200) + let proxySnapshot = Self.snapshot(cost: 2, now: now) + let staleProxyInput = SpendDashboardModel.ProviderInput( + id: SpendDashboardSource.codexProxySourceID, + provider: .codex, + displayName: "Codex · CLIProxyAPI", + modelProviderName: "Codex", + snapshot: proxySnapshot) + let request = SpendDashboardLoadRequest( + configuration: SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.claude.rawValue], + codexAccountIdentities: []), + capturedInputs: [staleProxyInput], + unavailableSourceIDs: [], + codexRequests: [], + now: now, + force: false) + let initialGuard = CLIProxyAPIAttributionPublicationGuard( + configurationGeneration: "generation-a", + telemetryRevision: "telemetry-a", + isIsolated: false) + let changedGuards = [ + CLIProxyAPIAttributionPublicationGuard( + configurationGeneration: "generation-b", + telemetryRevision: "telemetry-a", + isIsolated: false), + CLIProxyAPIAttributionPublicationGuard( + configurationGeneration: "generation-a", + telemetryRevision: "telemetry-b", + isIsolated: false), + CLIProxyAPIAttributionPublicationGuard( + configurationGeneration: "generation-a", + telemetryRevision: "telemetry-a", + isIsolated: true), + ] + + for changedGuard in changedGuards { + let guardLoadCount = LockIsolated(0) + let result = await SpendDashboardSource.load( + request, + codexSnapshotLoader: { _ in + Issue.record("No account-scoped Codex snapshot should be requested.") + return proxySnapshot + }, + codexProxySnapshotLoader: { _ in proxySnapshot }, + codexProxyAttributionGuardLoader: { + let loadCount = guardLoadCount.value + guardLoadCount.setValue(loadCount + 1) + return loadCount == 0 ? initialGuard : changedGuard + }) + + #expect(result.inputs.isEmpty) + #expect(result.failedSourceIDs == [SpendDashboardSource.codexProxySourceID]) + #expect(result.invalidatedSourceIDs == [SpendDashboardSource.codexProxySourceID]) + #expect(guardLoadCount.value == 2) + } + } + @Test func `cancelled proxy load preserves direct account and invalidates retained proxy source`() async { let now = Date(timeIntervalSince1970: 1_784_179_200) From 778205134b8d73fec6019a9cd078a7111fd829e6 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 25 Aug 2026 07:57:12 -0700 Subject: [PATCH 136/183] Keep Claude proxy totals provider-owned --- Sources/CodexBarCore/CostUsageModels.swift | 8 ++++++++ .../CostUsage/CostUsageScanner+Claude.swift | 4 +++- ...CostUsageFetcherUnknownModelPricingTests.swift | 15 ++++++++++----- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index abf16dc080..b85e0f7d62 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -386,6 +386,14 @@ public struct CostUsageAttribution: Sendable, Codable, Equatable, Hashable { .caseInsensitiveCompare("codex") == .orderedSame } + public var isAnthropic: Bool { + // Provider-specific by design: CLIProxyAPI may identify Anthropic with either external raw name. + switch self.provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "anthropic", "claude": true + default: false + } + } + public var providerDisplayName: String { // Provider-specific by design: external CLIProxyAPI provider IDs map to stable user-facing names. switch self.provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index fb68649adb..9e1768690e 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -1363,6 +1363,8 @@ extension CostUsageScanner { nil } let isCodexBackend = attribution?.route == .cliProxyAPI && attribution?.upstream?.isCodex == true + let isNonClaudeProxyBackend = attribution?.route == .cliProxyAPI && + attribution?.upstream?.isAnthropic != true let isUnresolvedAttribution = if attribution?.route == .cliProxyAPI { attribution?.upstream == nil } else { @@ -1371,7 +1373,7 @@ extension CostUsageScanner { let includeRow = switch attributionContext.filter { case .all: true case .codexBackendOnly: isCodexBackend - case .excludeCodexBackend: !isCodexBackend && !isUnresolvedAttribution + case .excludeCodexBackend: !isCodexBackend && !isNonClaudeProxyBackend && !isUnresolvedAttribution } guard includeRow else { continue } diff --git a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift index ba63414cbf..2309662ad6 100644 --- a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift @@ -379,15 +379,16 @@ struct CostUsageFetcherUnknownModelPricingTests { } @Test(arguments: [ - ("gpt-new", "openrouter", "OpenAICompatExecutor", 0.00028), - ("claude-new", "openrouter", "OpenAICompatExecutor", 0.00045), - ("gemma-new", "gemini", nil, 0.00036), + ("claude-new", "claude", "ClaudeExecutor", 0.00045, true), + ("gpt-new", "openrouter", "OpenAICompatExecutor", 0.00028, false), + ("gemma-new", "gemini", nil, 0.00036, false), ]) - func `claude fetch resolves proxy pricing across upstream providers`( + func `claude fetch retains only anthropic proxy upstreams`( upstreamModel: String, upstreamProvider: String, executorType: String?, - expectedCost: Double) async throws + expectedCost: Double, + shouldInclude: Bool) async throws { let environment = try CostUsageTestEnvironment() defer { environment.cleanup() } @@ -482,6 +483,10 @@ struct CostUsageFetcherUnknownModelPricingTests { modelsDevClient: ModelsDevClient(transport: CostUsageFetcherModelsDevTransport( data: refreshedCatalog))) + guard shouldInclude else { + #expect(snapshot.daily.isEmpty) + return + } let breakdown = try #require(snapshot.daily.first?.modelBreakdowns?.first) #expect(breakdown.modelName == alias) #expect(breakdown.attribution?.upstream?.provider == upstreamProvider) From a90c6c2745def1b5b7d90c1f4d327ab9eed72c05 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 25 Aug 2026 08:52:35 -0700 Subject: [PATCH 137/183] Keep disconnected Claude totals provider-owned --- Sources/CodexBarCore/CostUsageFetcher.swift | 4 +- .../CostUsage/CostUsageScanner+Claude.swift | 10 +---- ...UsageFetcherCLIProxyConcurrencyTests.swift | 42 +++++++++---------- ...ageFetcherCachedProxyDisconnectTests.swift | 12 ++++-- 4 files changed, 33 insertions(+), 35 deletions(-) diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index dbde3c3e6d..9ee4963c4b 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -601,9 +601,9 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: allowVertexClaudeFallback, forceRefresh: forceRefresh, bypassScannerDebounce: bypassScannerDebounce) - // Provider-specific by design: Claude's view excludes rows reassigned to the Codex proxy source. + // Provider-specific by design: Claude's view retains only Anthropic-owned rows, even after disconnect. if provider == .claude { - options.claudeAttributionFilter = cliProxyAPIAttributionEnabled ? .excludeCodexBackend : .all + options.claudeAttributionFilter = .excludeCodexBackend } var resolvedPiOptions = overridePiScannerOptions ?? PiSessionCostScanner.Options() if resolvedPiOptions.cacheRoot == nil { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 9e1768690e..34cdd62cfa 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -1175,18 +1175,10 @@ extension CostUsageScanner { calendar: range.calendar, checkCancellation: checkCancellation) } - let reportAttributionFilter: ClaudeAttributionFilter = if reportAttributionEnabled { - options.claudeAttributionFilter - } else { - switch options.claudeAttributionFilter { - case .all, .excludeCodexBackend: .all - case .codexBackendOnly: .codexBackendOnly - } - } let built = Self.buildClaudeReportFromCache( cache: cache, range: range, - attributionFilter: reportAttributionFilter, + attributionFilter: options.claudeAttributionFilter, attributionResolver: reportAttributionEnabled ? attributionResolver : nil, allowCachedCLIProxyAPIAttribution: reportAttributionEnabled, modelsDevCatalog: modelsDevCatalogResolver.resolve(), diff --git a/Tests/CodexBarTests/CostUsageFetcherCLIProxyConcurrencyTests.swift b/Tests/CodexBarTests/CostUsageFetcherCLIProxyConcurrencyTests.swift index e8ba386f7e..5deac42160 100644 --- a/Tests/CodexBarTests/CostUsageFetcherCLIProxyConcurrencyTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherCLIProxyConcurrencyTests.swift @@ -12,9 +12,9 @@ struct CostUsageFetcherCLIProxyConcurrencyTests { func assistant( sessionID: String, requestID: String, + model: String, seconds: TimeInterval, - input: Int, - output: Int) -> [String: Any] + tokens: (input: Int, output: Int)) -> [String: Any] { [ "type": "assistant", @@ -23,8 +23,8 @@ struct CostUsageFetcherCLIProxyConcurrencyTests { "requestId": requestID, "message": [ "id": "message-\(requestID)", - "model": "gpt-5.5", - "usage": ["input_tokens": input, "output_tokens": output], + "model": model, + "usage": ["input_tokens": tokens.input, "output_tokens": tokens.output], ], ] } @@ -34,23 +34,23 @@ struct CostUsageFetcherCLIProxyConcurrencyTests { assistant( sessionID: "session-codex", requestID: "codex", + model: "gpt-5.5", seconds: 0, - input: 10, - output: 2), + tokens: (input: 10, output: 2)), assistant( - sessionID: "session-openrouter", - requestID: "openrouter", + sessionID: "session-claude", + requestID: "claude", + model: "claude-sonnet-4-6", seconds: 2, - input: 100, - output: 20), + tokens: (input: 100, output: 20)), ])) let cliProxyHome = env.root.appendingPathComponent("cli-proxy-api", isDirectory: true) let logs = cliProxyHome.appendingPathComponent("logs", isDirectory: true) try FileManager.default.createDirectory(at: logs, withIntermediateDirectories: true) - for (name, sessionID, seconds) in [ - ("codex", "session-codex", 0.0), - ("openrouter", "session-openrouter", 2.0), + for (name, sessionID, model, seconds) in [ + ("codex", "session-codex", "gpt-5.5", 0.0), + ("claude", "session-claude", "claude-sonnet-4-6", 2.0), ] { let log = """ === REQUEST INFO === @@ -59,7 +59,7 @@ struct CostUsageFetcherCLIProxyConcurrencyTests { === HEADERS === X-Claude-Code-Session-Id: \(sessionID) === REQUEST BODY === - {"model":"gpt-5.5"} + {"model":"\(model)"} === API RESPONSE === """ try Data(log.utf8).write(to: logs.appendingPathComponent("\(name).log")) @@ -78,13 +78,13 @@ struct CostUsageFetcherCLIProxyConcurrencyTests { tokens: .init(input: 10, output: 2, total: 12)), CLIProxyAPIUsageRecord( timestamp: day.addingTimeInterval(2), - provider: "openrouter", - executorType: "OpenAICompatExecutor", - model: "gpt-5.5", - alias: "gpt-5.5", + provider: "claude", + executorType: "ClaudeExecutor", + model: "claude-sonnet-4-6", + alias: "claude-sonnet-4-6", endpoint: "/v1/messages", - authType: "api_key", - requestID: "cliproxy-openrouter", + authType: "oauth", + requestID: "cliproxy-claude", tokens: .init(input: 100, output: 20, total: 120)), ], cacheRoot: env.cacheRoot, @@ -113,6 +113,6 @@ struct CostUsageFetcherCLIProxyConcurrencyTests { #expect(codex.daily.first?.totalTokens == 12) #expect(codexBreakdown.attribution?.upstream?.provider == "codex") #expect(claude.daily.first?.totalTokens == 120) - #expect(claudeBreakdown.attribution?.upstream?.provider == "openrouter") + #expect(claudeBreakdown.attribution?.upstream?.provider == "claude") } } diff --git a/Tests/CodexBarTests/CostUsageFetcherCachedProxyDisconnectTests.swift b/Tests/CodexBarTests/CostUsageFetcherCachedProxyDisconnectTests.swift index 6e13b533c8..965569c48e 100644 --- a/Tests/CodexBarTests/CostUsageFetcherCachedProxyDisconnectTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherCachedProxyDisconnectTests.swift @@ -194,8 +194,14 @@ struct CostUsageFetcherCachedProxyDisconnectTests { #expect(claudeReport.data.first?.modelBreakdowns?.first?.attribution == nil) } - @Test(arguments: ["claude-sonnet-4-6", "gpt-5.5"]) - func `disconnect strips surviving cached proxy attribution`(model: String) async throws { + @Test(arguments: [ + ("claude-sonnet-4-6", true), + ("gpt-5.5", false), + ]) + func `disconnect strips cached attribution and retains only Claude models`( + model: String, + shouldRemainInClaude: Bool) async throws + { let env = try CostUsageTestEnvironment() defer { env.cleanup() } @@ -272,7 +278,7 @@ struct CostUsageFetcherCachedProxyDisconnectTests { allowPricingRefresh: false, includePiSessions: false, scannerOptions: options) - #expect(disconnectedClaude.daily.first?.totalTokens == 105) + #expect(disconnectedClaude.daily.first?.totalTokens == (shouldRemainInClaude ? 105 : nil)) #expect(disconnectedClaude.daily.first?.modelBreakdowns?.first?.attribution?.route != .cliProxyAPI) let cachedCodex = await CostUsageFetcher.loadCachedCodexTokenSnapshot( now: day, From 54211a65002765251cd86525f27ced8030cd4542 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 25 Aug 2026 09:29:49 -0700 Subject: [PATCH 138/183] Align proxy attribution test expectations --- Tests/CodexBarTests/CostUsageFetcherTests.swift | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index 2f5ab667fd..e023fcc4bf 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -1357,7 +1357,7 @@ extension CostUsageFetcherTests { } @Test - func `claude report preserves non codex proxy backend attribution`() async throws { + func `claude report preserves only anthropic proxy backend attribution`() async throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } @@ -1448,14 +1448,10 @@ extension CostUsageFetcherTests { let breakdowns = try #require(snapshot.daily.first?.modelBreakdowns) let claude = try #require(breakdowns.first { $0.modelName == "claude-sonnet-4-6" }) - let gemini = try #require(breakdowns.first { $0.modelName == "gemini-3-pro" }) #expect(claude.attribution?.route == .cliProxyAPI) #expect(claude.attribution?.upstream?.provider == "claude") #expect(claude.attribution?.upstream?.authType == .oauth) - #expect(gemini.attribution?.route == .cliProxyAPI) - #expect(gemini.attribution?.upstream?.provider == "gemini") - #expect(gemini.attribution?.upstream?.authType == .oauth) - #expect(gemini.costUSD == nil) + #expect(breakdowns.contains { $0.modelName == "gemini-3-pro" } == false) } @Test From 671c5c978750568f92e84f9cb3adfcdb43ac219d Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 25 Aug 2026 10:17:09 -0700 Subject: [PATCH 139/183] Invalidate proxy attribution before connection tests --- Sources/CodexBar/PreferencesSpendDashboardPane.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index d3dac243d9..d073965b82 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -627,6 +627,7 @@ struct SpendDashboardPane: View { self.cliProxyAPIManagementKey = "" self.cliProxyAPIHasSavedConfiguration = true + self.store.invalidateCLIProxyAPICostAttribution(widgetReason: "cliproxyapi-configuration-changed") switch await self.store.collectCLIProxyAPIUsageNow() { case .disabled: self.cliProxyAPIStatus = "Enable Track costs to test." From e8df86a93938c028b743ce7429165e1dff53dc15 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 25 Aug 2026 18:36:18 -0700 Subject: [PATCH 140/183] Parse proxy aliases independent of key order --- .../CLIProxyAPIAttributionResolver.swift | 28 +++++++++++++++---- .../CLIProxyAPIAliasRegressionTests.swift | 17 +++++++++++ 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift index bbfe59c473..68a0003b24 100644 --- a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift +++ b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift @@ -670,6 +670,8 @@ struct CLIProxyAPIAttributionResolver: Sendable { var rootIndent: Int? var codexIndent: Int? var currentName: String? + var currentAlias: String? + var currentItemIndent: Int? for rawLine in text.split(whereSeparator: \.isNewline) { let line = String(rawLine) @@ -699,14 +701,28 @@ struct CLIProxyAPIAttributionResolver: Sendable { if indent <= codexIndent { break } - if trimmed.hasPrefix("- name:") { - currentName = self.simpleYAMLScalar(String(trimmed.dropFirst("- name:".count))) - } else if trimmed.hasPrefix("alias:"), let currentName { - let alias = self.simpleYAMLScalar(String(trimmed.dropFirst("alias:".count))) - if !alias.isEmpty, !currentName.isEmpty { - aliases[alias] = currentName + + let startsItem = trimmed.hasPrefix("- ") + if startsItem { + if let currentName, let currentAlias, !currentName.isEmpty, !currentAlias.isEmpty { + aliases[currentAlias] = currentName } + (currentName, currentAlias) = (nil, nil) + currentItemIndent = indent + } + guard startsItem || currentItemIndent.map({ indent > $0 }) == true else { + (currentName, currentAlias, currentItemIndent) = (nil, nil, nil) + continue } + let field = startsItem ? String(trimmed.dropFirst(2)) : trimmed + if field.hasPrefix("name:") { + currentName = self.simpleYAMLScalar(String(field.dropFirst("name:".count))) + } else if field.hasPrefix("alias:") { + currentAlias = self.simpleYAMLScalar(String(field.dropFirst("alias:".count))) + } + } + if let currentName, let currentAlias, !currentName.isEmpty, !currentAlias.isEmpty { + aliases[currentAlias] = currentName } return aliases } diff --git a/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift b/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift index 59717bacdb..fba3933c38 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift @@ -78,6 +78,23 @@ struct CLIProxyAPIAliasRegressionTests { ]) } + @Test + func `codex oauth alias parser accepts alias before name`() { + let configuration = """ + oauth-model-alias: + codex: + - alias: 'proxy-codex-alias' + name: 'gpt-5.5' + - name: 'gpt-5.6' + alias: 'proxy-codex-second' + """ + + #expect(CLIProxyAPIAttributionResolver.parseCodexOAuthModelAliases(configuration) == [ + "proxy-codex-alias": "gpt-5.5", + "proxy-codex-second": "gpt-5.6", + ]) + } + @Test func `weaker live route evidence preserves cached telemetry upstream`() { let cached = CostUsageAttribution( From cdf53ee3ba1a264b4f584a056c81ec71b8a5e882 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 25 Aug 2026 18:55:11 -0700 Subject: [PATCH 141/183] Guard proxy publication artifacts --- Sources/CodexBar/SpendDashboardController.swift | 3 +++ Sources/CodexBar/UsageStore+TokenCost.swift | 11 +++++------ Sources/CodexBarCore/CostUsageFetcher.swift | 16 ++++++++++++++++ .../CLIProxyAPIPublicationGuardTests.swift | 16 +++++++++++++++- .../SpendDashboardCodexProxySourceTests.swift | 9 +++++++++ 5 files changed, 48 insertions(+), 7 deletions(-) diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index d384af9995..373dfc1e00 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -495,6 +495,7 @@ enum SpendDashboardSource { // swiftlint:disable:this type_body_length CLIProxyAPIAttributionPublicationGuard( configurationGeneration: nil, telemetryRevision: nil, + inputArtifactFingerprint: nil, isIsolated: false) }) async -> SpendDashboardLoadResult { @@ -517,6 +518,7 @@ enum SpendDashboardSource { // swiftlint:disable:this type_body_length CLIProxyAPIAttributionPublicationGuard( configurationGeneration: nil, telemetryRevision: nil, + inputArtifactFingerprint: nil, isIsolated: false) }) async -> SpendDashboardLoadResult { @@ -715,6 +717,7 @@ enum SpendDashboardSource { // swiftlint:disable:this type_body_length return CLIProxyAPIAttributionPublicationGuard( configurationGeneration: fetcher.cliProxyAPIConfigurationGeneration(), telemetryRevision: fetcher.cliProxyAPIUsageTelemetryRevision(), + inputArtifactFingerprint: fetcher.cliProxyAPIInputArtifactFingerprint(), isIsolated: fetcher.cliProxyAPIAttributionIsIsolated()) } diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index cd59a9affb..fd664c54fa 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -21,6 +21,7 @@ struct TokenRefreshPublicationGuard { struct CLIProxyAPIAttributionPublicationGuard: Sendable, Equatable { let configurationGeneration: String? let telemetryRevision: String? + let inputArtifactFingerprint: String? let isIsolated: Bool } @@ -143,6 +144,7 @@ extension UsageStore { CLIProxyAPIAttributionPublicationGuard( configurationGeneration: self.costUsageFetcher.cliProxyAPIConfigurationGeneration(), telemetryRevision: self.costUsageFetcher.cliProxyAPIUsageTelemetryRevision(), + inputArtifactFingerprint: self.costUsageFetcher.cliProxyAPIInputArtifactFingerprint(), isIsolated: self.costUsageFetcher.cliProxyAPIAttributionIsIsolated()) } @@ -153,6 +155,7 @@ extension UsageStore { guard provider == .codex || provider == .claude else { return true } return self.costUsageFetcher.cliProxyAPIConfigurationGeneration() == guardValue.configurationGeneration && self.costUsageFetcher.cliProxyAPIUsageTelemetryRevision() == guardValue.telemetryRevision + && self.costUsageFetcher.cliProxyAPIInputArtifactFingerprint() == guardValue.inputArtifactFingerprint && self.costUsageFetcher.cliProxyAPIAttributionIsIsolated() == guardValue.isIsolated } @@ -287,9 +290,7 @@ extension UsageStore { let costUsageSettingsRevision = self.settings.costUsageSettingsRevision let tokenSnapshotScopeSignature = self.tokenSnapshotScopeSignature(for: .codex) let tokenSnapshotPublicationRevision = self.tokenSnapshotPublicationRevision(for: .codex) - let cliProxyAPIConfigurationGeneration = self.costUsageFetcher.cliProxyAPIConfigurationGeneration() - let cliProxyAPIUsageTelemetryRevision = self.costUsageFetcher.cliProxyAPIUsageTelemetryRevision() - let cliProxyAPIAttributionIsIsolated = self.costUsageFetcher.cliProxyAPIAttributionIsIsolated() + let cliProxyAPIAttributionGuard = self.cliProxyAPIAttributionPublicationGuard() return Task { @MainActor [weak self] in guard let self else { return } guard self.tokenSnapshotPublicationForCurrentProviderConfig(for: .codex) == nil else { return } @@ -325,9 +326,7 @@ extension UsageStore { self.settings.costUsageHistoryDays == historyDays, self.tokenSnapshotScopeSignature(for: .codex) == tokenSnapshotScopeSignature, self.tokenSnapshotPublicationRevision(for: .codex) == tokenSnapshotPublicationRevision, - self.costUsageFetcher.cliProxyAPIConfigurationGeneration() == cliProxyAPIConfigurationGeneration, - self.costUsageFetcher.cliProxyAPIUsageTelemetryRevision() == cliProxyAPIUsageTelemetryRevision, - self.costUsageFetcher.cliProxyAPIAttributionIsIsolated() == cliProxyAPIAttributionIsIsolated, + self.cliProxyAPIAttributionPublicationIsCurrent(cliProxyAPIAttributionGuard, for: .codex), self.tokenSnapshotPublicationForCurrentProviderConfig(for: .codex) == nil else { return diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 33ac70d22e..cfbee541b3 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -358,6 +358,22 @@ public struct CostUsageFetcher: Sendable { return CLIProxyAPIUsageTelemetryRevision.current(cacheRoot: options.cacheRoot) } + package func cliProxyAPIInputArtifactFingerprint() -> String? { + let options = Self.resolvedScannerOptions( + self.scannerOptionsOverride(), + provider: .codex, + codexHomePath: nil) + let attributionEnabled = !CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( + stateRoot: options.cacheRoot) + guard let fingerprint = try? CostUsageScanner.currentClaudeCLIProxyAPIInputArtifactFingerprint( + options: options, + attributionEnabled: attributionEnabled) + else { return nil } + return fingerprint.sorted { $0.key < $1.key }.map { path, stamp in + "\(path)|\(stamp.fileID)|\(stamp.size)|\(stamp.modifiedSeconds)|\(stamp.modifiedNanoseconds)" + }.joined(separator: "\n") + } + package func cliProxyAPIAttributionIsIsolated() -> Bool { let options = Self.resolvedScannerOptions( self.scannerOptionsOverride(), diff --git a/Tests/CodexBarTests/CLIProxyAPIPublicationGuardTests.swift b/Tests/CodexBarTests/CLIProxyAPIPublicationGuardTests.swift index b82612eeda..b86a07656c 100644 --- a/Tests/CodexBarTests/CLIProxyAPIPublicationGuardTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIPublicationGuardTests.swift @@ -14,10 +14,14 @@ struct CLIProxyAPIPublicationGuardTests { let root = FileManager.default.temporaryDirectory .appendingPathComponent("cliproxy-live-publication-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager.default.removeItem(at: root) } + let proxyHome = root.appendingPathComponent("cli-proxy-api", isDirectory: true) + var scannerOptions = CostUsageScanner.Options() + scannerOptions.cacheRoot = root + scannerOptions.cliProxyAPIHome = proxyHome let store = UsageStore( fetcher: UsageFetcher(environment: [:]), browserDetection: BrowserDetection(cacheTTL: 0), - costUsageFetcher: CostUsageFetcher(cacheRoot: root), + costUsageFetcher: CostUsageFetcher(scannerOptions: scannerOptions), settings: settings, startupBehavior: .testing, environmentBase: [:]) @@ -60,6 +64,16 @@ struct CLIProxyAPIPublicationGuardTests { historyDays: historyDays, costScopeSignature: scopeSignature)) + let artifactGuard = store.tokenRefreshPublicationGuard(for: .codex) + let logDirectory = proxyHome.appendingPathComponent("logs", isDirectory: true) + try FileManager.default.createDirectory(at: logDirectory, withIntermediateDirectories: true) + try Data("request".utf8).write(to: logDirectory.appendingPathComponent("request.log")) + #expect(!store.tokenRefreshPublicationIsCurrent( + provider: .codex, + publicationGuard: artifactGuard, + historyDays: historyDays, + costScopeSignature: scopeSignature)) + let isolationGuard = store.tokenRefreshPublicationGuard(for: .codex) #expect(CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected(true, stateRoot: root)) #expect(!store.tokenRefreshPublicationIsCurrent( diff --git a/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift b/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift index 3121c1e6c5..74ec939347 100644 --- a/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift +++ b/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift @@ -103,19 +103,28 @@ struct SpendDashboardCodexProxySourceTests { let initialGuard = CLIProxyAPIAttributionPublicationGuard( configurationGeneration: "generation-a", telemetryRevision: "telemetry-a", + inputArtifactFingerprint: "artifact-a", isIsolated: false) let changedGuards = [ CLIProxyAPIAttributionPublicationGuard( configurationGeneration: "generation-b", telemetryRevision: "telemetry-a", + inputArtifactFingerprint: "artifact-a", isIsolated: false), CLIProxyAPIAttributionPublicationGuard( configurationGeneration: "generation-a", telemetryRevision: "telemetry-b", + inputArtifactFingerprint: "artifact-a", isIsolated: false), CLIProxyAPIAttributionPublicationGuard( configurationGeneration: "generation-a", telemetryRevision: "telemetry-a", + inputArtifactFingerprint: "artifact-b", + isIsolated: false), + CLIProxyAPIAttributionPublicationGuard( + configurationGeneration: "generation-a", + telemetryRevision: "telemetry-a", + inputArtifactFingerprint: "artifact-a", isIsolated: true), ] From be83254a10bfc4ea844d9857a0c395d5e3365349 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 25 Aug 2026 18:57:57 -0700 Subject: [PATCH 142/183] Keep proxy spend source distinct --- .../CodexBar/SpendDashboardController.swift | 3 +- Sources/CodexBar/SpendDashboardModel.swift | 1 + .../SpendDashboardSource+OpenCodex.swift | 4 ++- .../OpenCodexUsageFanOutTests.swift | 13 ++++++++ .../SpendDashboardCodexProxySourceTests.swift | 33 +++++++++++++++++++ 5 files changed, 52 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index 373dfc1e00..4dbe1453c0 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -638,7 +638,8 @@ enum SpendDashboardSource { // swiftlint:disable:this type_body_length provider: .codex, displayName: "\(providerName) · CLIProxyAPI", modelProviderName: providerName, - snapshot: snapshot)) + snapshot: snapshot, + sourceKind: .cliProxyAPI)) } } catch is CancellationError { inputs.removeAll { $0.id == Self.codexProxySourceID } diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index c7f963b013..549bde6085 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -6,6 +6,7 @@ struct SpendDashboardModel: Equatable, Sendable { enum SourceKind: String, Sendable, Equatable { case native case openCodex + case cliProxyAPI } static let openCodexSourceID = "opencodex" diff --git a/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift b/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift index b4567bb4e7..eb82097837 100644 --- a/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift +++ b/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift @@ -85,7 +85,9 @@ extension SpendDashboardSource { // Provider-specific by design: OpenCodex fan-out merges into the native Codex subscription row when exactly one // exists. if provider == .codex { - let codexIndices = inputs.indices.filter { inputs[$0].provider == .codex } + let codexIndices = inputs.indices.filter { + inputs[$0].provider == .codex && inputs[$0].sourceKind == .native + } guard codexIndices.count == 1 else { return nil } return codexIndices.first } diff --git a/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift b/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift index 1d6f476610..d7732d9c99 100644 --- a/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift +++ b/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift @@ -121,6 +121,19 @@ struct OpenCodexUsageFanOutTests { ] #expect(SpendDashboardSource.preferredMergeIndex(for: .codex, in: singleCodex) == 0) + let proxyOnly = [ + SpendDashboardModel.ProviderInput( + id: SpendDashboardSource.codexProxySourceID, + provider: .codex, + displayName: "Codex · CLIProxyAPI", + snapshot: dummySnapshot, + sourceKind: .cliProxyAPI), + ] + #expect(SpendDashboardSource.preferredMergeIndex(for: .codex, in: proxyOnly) == nil) + + let nativeAndProxy = singleCodex + proxyOnly + #expect(SpendDashboardSource.preferredMergeIndex(for: .codex, in: nativeAndProxy) == 0) + let multipleCodex = [ SpendDashboardModel.ProviderInput( id: "codex:acct-1", diff --git a/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift b/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift index 74ec939347..da382d51cf 100644 --- a/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift +++ b/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift @@ -49,6 +49,7 @@ struct SpendDashboardCodexProxySourceTests { #expect(result.inputs.count { $0.id == SpendDashboardSource.codexProxySourceID } == 1) #expect(result.inputs.first { $0.id == SpendDashboardSource.codexProxySourceID }?.displayName == "Codex · CLIProxyAPI") + #expect(result.inputs.first { $0.id == SpendDashboardSource.codexProxySourceID }?.sourceKind == .cliProxyAPI) #expect(proxyContexts.count == 1) #expect(proxyContexts.first?.now == now) } @@ -80,6 +81,38 @@ struct SpendDashboardCodexProxySourceTests { #expect(result.inputs.map(\.id) == [SpendDashboardSource.codexProxySourceID]) } + @Test + func `proxy usage stays visible when OpenCodex hides native Codex`() { + let now = Date(timeIntervalSince1970: 1_784_179_200) + let inputs = [ + SpendDashboardModel.ProviderInput( + id: "codex:main", provider: .codex, displayName: "Codex", snapshot: Self.snapshot(cost: 1, now: now)), + SpendDashboardModel.ProviderInput( + id: SpendDashboardModel.openCodexSourceID, + provider: .codex, + displayName: "OpenCodex", + snapshot: Self.snapshot(cost: 2, now: now), + sourceKind: .openCodex), + SpendDashboardModel.ProviderInput( + id: SpendDashboardSource.codexProxySourceID, + provider: .codex, + displayName: "Codex · CLIProxyAPI", + snapshot: Self.snapshot(cost: 3, now: now), + sourceKind: .cliProxyAPI), + ] + + let model = SpendDashboardModel.build( + inputs: inputs, + requestedDays: 7, + now: now, + hideNativeCodexWhenOpenCodexPresent: true) + + #expect(Set(model.groups.flatMap(\.providers).map(\.id)) == [ + SpendDashboardModel.openCodexSourceID, + SpendDashboardSource.codexProxySourceID, + ]) + } + @Test func `proxy usage is invalidated when attribution boundaries change during load`() async { let now = Date(timeIntervalSince1970: 1_784_179_200) From dd0e838a86dc538f2ff83a748cb0dfac4c8421c5 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 25 Aug 2026 19:29:40 -0700 Subject: [PATCH 143/183] Preserve model counters in report merges --- Sources/CodexBarCore/CostUsageModels.swift | 42 +++++++++++++++++++ .../CostUsageDailyReportMergeTests.swift | 17 ++++++++ 2 files changed, 59 insertions(+) diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index b85e0f7d62..db7be602f5 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -828,6 +828,18 @@ extension CostUsageDailyReport { private struct BreakdownAccumulator { var totalTokens: Int = 0 var sawTotalTokens = false + var requestCount: Int = 0 + var sawRequestCount = false + var inputTokens: Int = 0 + var sawInputTokens = false + var outputTokens: Int = 0 + var sawOutputTokens = false + var cacheReadTokens: Int = 0 + var sawCacheReadTokens = false + var cacheCreationTokens: Int = 0 + var sawCacheCreationTokens = false + var reasoningTokens: Int = 0 + var sawReasoningTokens = false var costUSD: Double = 0 var sawCost = false var standardCostUSD: Double = 0 @@ -844,6 +856,30 @@ extension CostUsageDailyReport { self.totalTokens += totalTokens self.sawTotalTokens = true } + if let requestCount = breakdown.requestCount { + self.requestCount += requestCount + self.sawRequestCount = true + } + if let inputTokens = breakdown.inputTokens { + self.inputTokens += inputTokens + self.sawInputTokens = true + } + if let outputTokens = breakdown.outputTokens { + self.outputTokens += outputTokens + self.sawOutputTokens = true + } + if let cacheReadTokens = breakdown.cacheReadTokens { + self.cacheReadTokens += cacheReadTokens + self.sawCacheReadTokens = true + } + if let cacheCreationTokens = breakdown.cacheCreationTokens { + self.cacheCreationTokens += cacheCreationTokens + self.sawCacheCreationTokens = true + } + if let reasoningTokens = breakdown.reasoningTokens { + self.reasoningTokens += reasoningTokens + self.sawReasoningTokens = true + } if let costUSD = breakdown.costUSD { self.costUSD += costUSD self.sawCost = true @@ -871,6 +907,12 @@ extension CostUsageDailyReport { modelName: key.modelName, costUSD: self.sawCost ? self.costUSD : nil, totalTokens: self.sawTotalTokens ? self.totalTokens : nil, + requestCount: self.sawRequestCount ? self.requestCount : nil, + inputTokens: self.sawInputTokens ? self.inputTokens : nil, + outputTokens: self.sawOutputTokens ? self.outputTokens : nil, + cacheReadTokens: self.sawCacheReadTokens ? self.cacheReadTokens : nil, + cacheCreationTokens: self.sawCacheCreationTokens ? self.cacheCreationTokens : nil, + reasoningTokens: self.sawReasoningTokens ? self.reasoningTokens : nil, standardCostUSD: self.sawStandardCost ? self.standardCostUSD : nil, priorityCostUSD: self.sawPriorityCost ? self.priorityCostUSD : nil, standardTokens: self.sawStandardTokens ? self.standardTokens : nil, diff --git a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift index fcd6ec6564..5af765ca36 100644 --- a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift +++ b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift @@ -71,6 +71,11 @@ struct CostUsageDailyReportMergeTests { modelName: "gpt-5.4", costUSD: 1.25, totalTokens: 130, + requestCount: 3, + inputTokens: 100, + outputTokens: 20, + cacheReadTokens: 10, + reasoningTokens: 5, standardCostUSD: 0.75, priorityCostUSD: 0.50, standardTokens: 80, @@ -100,6 +105,12 @@ struct CostUsageDailyReportMergeTests { modelName: "gpt-5.4", costUSD: 0.75, totalTokens: 67, + requestCount: 2, + inputTokens: 50, + outputTokens: 10, + cacheReadTokens: 5, + cacheCreationTokens: 2, + reasoningTokens: 3, standardCostUSD: 0.25, priorityCostUSD: 0.50, standardTokens: 20, @@ -127,6 +138,12 @@ struct CostUsageDailyReportMergeTests { modelName: "gpt-5.4", costUSD: 2.0, totalTokens: 197, + requestCount: 5, + inputTokens: 150, + outputTokens: 30, + cacheReadTokens: 15, + cacheCreationTokens: 2, + reasoningTokens: 8, standardCostUSD: 1.0, priorityCostUSD: 1.0, standardTokens: 100, From b9807496bfb5c04f80f562685f70cd957e8326e0 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 26 Aug 2026 06:39:40 -0700 Subject: [PATCH 144/183] Preserve proxy-only share stats --- Sources/CodexBar/ShareStatsPayload.swift | 7 ++++- Tests/CodexBarTests/ShareStatsTests.swift | 32 +++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/ShareStatsPayload.swift b/Sources/CodexBar/ShareStatsPayload.swift index 0e8d98f877..3860f94e79 100644 --- a/Sources/CodexBar/ShareStatsPayload.swift +++ b/Sources/CodexBar/ShareStatsPayload.swift @@ -255,9 +255,14 @@ enum ShareStatsBuilder { model: SpendDashboardModel, subscriptionNames: [String: ShareStatsSubscriptionName] = [:]) -> ShareStatsPayload? { + let hasNonProxyProvider = model.groups.contains { group in + group.providers.contains { $0.id != SpendDashboardSource.codexProxySourceID } + } let providers = model.groups.flatMap { group in group.providers.compactMap { row -> ShareStatsProviderPayload? in - guard row.id != SpendDashboardSource.codexProxySourceID else { return nil } + // The proxy source is not an account or subscription. Keep excluding it beside native + // providers, but retain it when it is the only provider so proxy-only usage stays shareable. + guard row.id != SpendDashboardSource.codexProxySourceID || !hasNonProxyProvider else { return nil } return ShareStatsProviderPayload( provider: row.provider, providerName: row.displayName, diff --git a/Tests/CodexBarTests/ShareStatsTests.swift b/Tests/CodexBarTests/ShareStatsTests.swift index 62db597364..76678ea9b9 100644 --- a/Tests/CodexBarTests/ShareStatsTests.swift +++ b/Tests/CodexBarTests/ShareStatsTests.swift @@ -206,6 +206,38 @@ struct ShareStatsTests { #expect(ShareStatsBuilder.make(model: SpendDashboardModel(requestedDays: 30, groups: [])) == nil) } + @Test + func `proxy only dashboard remains shareable without presenting proxy as a subscription`() throws { + let group = SpendDashboardModel.CurrencyGroup( + currencyCode: "USD", + providers: [ + SpendDashboardModel.ProviderRow( + id: SpendDashboardSource.codexProxySourceID, + rank: 1, + provider: .codex, + displayName: "Codex · CLIProxyAPI", + totalTokens: 30, + totalCost: 1.5, + coveredDayCount: 1), + ], + models: [], + projects: [], + dailyPoints: [], + totalTokens: 30, + totalCost: 1.5, + coveredDayCount: 1, + chartDomain: Self.date...Self.date, + modelHistoryCompleteness: .complete) + + let payload = try #require(ShareStatsBuilder.make( + model: SpendDashboardModel(requestedDays: 1, groups: [group]))) + + #expect(payload.providers.count == 1) + #expect(payload.providers.first?.providerName == "Codex · CLIProxyAPI") + #expect(payload.providers.first?.subscriptionName == nil) + #expect(payload.providers.first?.estimatedCost == 1.5) + } + @Test func `cost only models do not enter token usage rankings`() throws { let model = SpendDashboardModel(requestedDays: 7, groups: [ From 3c5d073b5e392ac5dbaad3041a82743834e68e86 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 26 Aug 2026 06:39:40 -0700 Subject: [PATCH 145/183] Skip proxy guards for unrelated providers --- Sources/CodexBar/UsageStore+TokenCost.swift | 18 ++++++++++++------ .../CLIProxyAPIPublicationGuardTests.swift | 5 +++++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index fd664c54fa..eaedf72f3e 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -15,7 +15,7 @@ struct TokenRefreshPublicationGuard { let provider: UsageStore.ProviderPublicationRevision let tokenSnapshot: UInt64 let providerConfig: UInt64 - let cliProxyAPIAttribution: CLIProxyAPIAttributionPublicationGuard + let cliProxyAPIAttribution: CLIProxyAPIAttributionPublicationGuard? } struct CLIProxyAPIAttributionPublicationGuard: Sendable, Equatable { @@ -133,11 +133,16 @@ extension UsageStore { } func tokenRefreshPublicationGuard(for provider: UsageProvider) -> TokenRefreshPublicationGuard { - TokenRefreshPublicationGuard( + // Provider-specific by design: only Claude and Codex snapshots can contain CLIProxyAPI attribution. + let cliProxyAPIAttribution: CLIProxyAPIAttributionPublicationGuard? = switch provider { + case .claude, .codex: self.cliProxyAPIAttributionPublicationGuard() + default: nil + } + return TokenRefreshPublicationGuard( provider: self.providerPublicationRevision(for: provider), tokenSnapshot: self.tokenSnapshotPublicationRevision(for: provider), providerConfig: self.settings.providerConfigRevision(for: provider), - cliProxyAPIAttribution: self.cliProxyAPIAttributionPublicationGuard()) + cliProxyAPIAttribution: cliProxyAPIAttribution) } func cliProxyAPIAttributionPublicationGuard() -> CLIProxyAPIAttributionPublicationGuard { @@ -476,12 +481,13 @@ extension UsageStore { costScopeSignature: String, fetchedCredentialScopeFingerprint: String? = nil) -> Bool { + let cliProxyAPIAttributionIsCurrent = publicationGuard.cliProxyAPIAttribution.map { + self.cliProxyAPIAttributionPublicationIsCurrent($0, for: provider) + } ?? true guard self.providerPublicationRevisionIsCurrent(publicationGuard.provider, for: provider), self.tokenSnapshotPublicationRevision(for: provider) == publicationGuard.tokenSnapshot, self.settings.providerConfigRevision(for: provider) == publicationGuard.providerConfig, - self.cliProxyAPIAttributionPublicationIsCurrent( - publicationGuard.cliProxyAPIAttribution, - for: provider), + cliProxyAPIAttributionIsCurrent, self.settings.costUsageEnabled, self.isEnabled(provider), self.settings.costUsageHistoryDays == historyDays diff --git a/Tests/CodexBarTests/CLIProxyAPIPublicationGuardTests.swift b/Tests/CodexBarTests/CLIProxyAPIPublicationGuardTests.swift index b86a07656c..9f4dc4fef7 100644 --- a/Tests/CodexBarTests/CLIProxyAPIPublicationGuardTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIPublicationGuardTests.swift @@ -28,6 +28,11 @@ struct CLIProxyAPIPublicationGuardTests { let historyDays = settings.costUsageHistoryDays let scopeSignature = store.tokenSnapshotScopeSignature(for: .codex) + #expect(store.tokenRefreshPublicationGuard(for: .claude).cliProxyAPIAttribution != nil) + #expect(store.tokenRefreshPublicationGuard(for: .codex).cliProxyAPIAttribution != nil) + #expect(store.tokenRefreshPublicationGuard(for: .cursor).cliProxyAPIAttribution == nil) + #expect(store.tokenRefreshPublicationGuard(for: .gemini).cliProxyAPIAttribution == nil) + let generationGuard = store.tokenRefreshPublicationGuard(for: .codex) let artifactDirectory = root.appendingPathComponent("cost-usage", isDirectory: true) try FileManager.default.createDirectory(at: artifactDirectory, withIntermediateDirectories: true) From 2dec313d60b0b8c9e4e350827dbd68d504dd72b2 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 26 Aug 2026 06:39:40 -0700 Subject: [PATCH 146/183] Align provider gatekeeper anchors --- .../ProviderArchitectureGatekeeperTests.swift | 103 ++++++++++-------- 1 file changed, 55 insertions(+), 48 deletions(-) diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 4f168d6fde..40c2abf43c 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -909,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: 570, + line: 572, 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: 573, + line: 575, 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: 636, + line: 638, 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: 761, + line: 765, 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: 1814, + line: 1818, 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: 1843, + line: 1847, 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: 1860, + line: 1864, anchor: "if sourceID.hasPrefix(\"codex:\") { return .codex }", expectedProviderIDs: ["codex"], reason: "This publication projection maps stable Codex account source IDs back to their provider family."), @@ -1209,68 +1209,68 @@ struct ProviderArchitectureGatekeeperTests { reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 86, + line: 87, anchor: "allowVertexClaudeFallback: !self.isEnabled(.claude),", expectedProviderIDs: ["claude"], reason: "The local transcript scan permits Vertex fallback only when Claude is disabled to avoid " + "double-counting the same logs."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 283, + line: 291, anchor: "let scope = self.tokenCostScope(for: .codex)", expectedProviderIDs: ["codex"], reason: "The Codex-only cache hydration path passes its fixed provider identity to shared state helpers."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 285, + line: 293, anchor: "let publicationRevision = self.providerPublicationRevision(for: .codex)", expectedProviderIDs: ["codex"], reason: "The Codex-only cache hydration path passes its fixed provider identity to shared state helpers."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 286, + line: 294, anchor: "let providerConfigRevision = self.settings.providerConfigRevision(for: .codex)", expectedProviderIDs: ["codex"], reason: "The Codex-only cache hydration path passes its fixed provider identity to shared state helpers."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 288, + line: 296, anchor: "let tokenSnapshotScopeSignature = self.tokenSnapshotScopeSignature(for: .codex)", expectedProviderIDs: ["codex"], reason: "The Codex-only cache hydration path passes its fixed provider identity to shared state helpers."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 289, + line: 297, anchor: "let tokenSnapshotPublicationRevision = self.tokenSnapshotPublicationRevision(for: .codex)", expectedProviderIDs: ["codex"], reason: "The Codex-only cache hydration path passes its fixed provider identity to shared state helpers."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 322, + line: 328, anchor: "self.settings.isCostUsageEffectivelyEnabled(for: .codex),", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 323, + line: 329, anchor: "self.isEnabled(.codex),", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 335, + line: 339, anchor: "self.installCachedTokenSnapshot(result.snapshot, for: .codex)", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 435, + line: 439, anchor: "let credentialFingerprint = CookieHeaderCache.loadForDisplay(provider: .cursor)", expectedProviderIDs: ["cursor"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 450, + line: 454, anchor: "let scope = self.tokenCostScope(for: .cursor)", expectedProviderIDs: ["cursor"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), @@ -1378,25 +1378,31 @@ struct ProviderArchitectureGatekeeperTests { reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 376, + line: 380, 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: 946, + line: 409, 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: 1027, + line: 962, 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: 1137, + line: 1043, + 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: 1153, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), @@ -2337,7 +2343,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 750, + line: 754, anchor: "(providers.contains(.codex) && settings.codexLocalSessionCostLedgerEnabled)", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2377,7 +2383,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: 812, + line: 816, anchor: "if providers.contains(.codex) {", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2385,7 +2391,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: 840, + line: 844, anchor: "if providers.contains(.codex) {", expectedProviderIDs: ["codex"], expectedReferenceCount: 3, @@ -2393,7 +2399,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: 1887, + line: 1891, anchor: "guard input.provider == .codex,", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2409,7 +2415,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardModel.swift", - line: 1098, + line: 1099, anchor: "guard provider == .mistral || provider == .openrouter || provider == .xai else { return displayCalendar }", expectedProviderIDs: ["mistral", "openrouter", "xai"], expectedReferenceCount: 3, @@ -3143,15 +3149,15 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 153, + line: 160, anchor: "guard provider == .codex || provider == .claude else { return true }", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 3, - expectedReferenceFingerprint: ["claude@0", "codex@0", "codex@10"], + expectedReferenceFingerprint: ["claude@0", "codex@0", "codex@11"], reason: "CLIProxyAPI attribution guards apply only to providers that can contain proxy-routed usage."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 295, + line: 301, anchor: "guard self.tokenSnapshotPublicationForCurrentProviderConfig(for: .codex) == nil else { return }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3159,25 +3165,26 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 319, + line: 325, anchor: "guard self.providerPublicationRevisionIsCurrent(publicationRevision, for: .codex),", expectedProviderIDs: ["codex"], - expectedReferenceCount: 9, + expectedReferenceCount: 10, expectedReferenceFingerprint: [ "codex@0", "codex@1", "codex@5", "codex@7", "codex@8", - "codex@12", - "codex@17", - "codex@26", - "codex@27", + "codex@9", + "codex@10", + "codex@15", + "codex@24", + "codex@25", ], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 359, + line: 363, anchor: "return provider == .codex && self.codexCostCatchUpActivity?.phase == .indexing", expectedProviderIDs: ["claude", "codex", "vertexai"], expectedReferenceCount: 4, @@ -3185,7 +3192,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 424, + line: 428, anchor: "guard provider == .cursor else {", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3193,7 +3200,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 493, + line: 498, anchor: "if provider == .cursor,", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3201,7 +3208,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 514, + line: 519, anchor: "guard provider == .cursor,", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3209,7 +3216,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 536, + line: 541, anchor: "case .openai:", expectedProviderIDs: ["grok", "mistral", "openai", "opencodego", "openrouter", "xai"], expectedReferenceCount: 12, @@ -3230,7 +3237,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 618, + line: 623, anchor: "self.tokenFailureGates[.codex]?.reset()", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 2, @@ -3520,7 +3527,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: 733, + line: 749, anchor: "if provider == .codex {", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3528,7 +3535,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: 763, + line: 779, anchor: "provider == .claude || (provider == .codex && options.shouldMergeGlobalCodexUsage)", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 4, @@ -3536,7 +3543,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: 813, + line: 829, anchor: "options.provider == .codex || options.provider == .claude", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 2, @@ -3544,7 +3551,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: 844, + line: 860, anchor: "guard provider == .codex || provider == .claude else { return nil }", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 2, @@ -3552,7 +3559,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: 1768, + line: 1784, anchor: "if provider == .vertexai {", expectedProviderIDs: ["claude", "vertexai"], expectedReferenceCount: 2, @@ -3560,7 +3567,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: 2158, + line: 2174, anchor: "if provider == .cursor {", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, From 0a4b2b471596116c22cf93576368978987c7cfdd Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 26 Aug 2026 06:59:39 -0700 Subject: [PATCH 147/183] Guard merged usage totals from overflow --- Sources/CodexBarCore/CostUsageModels.swift | 315 +++++++----------- .../CostUsageDailyReportMergeTests.swift | 61 ++++ 2 files changed, 190 insertions(+), 186 deletions(-) diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index db7be602f5..92c577b650 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -820,66 +820,63 @@ public struct CostUsageDailyReport: Sendable, Decodable { } extension CostUsageDailyReport { + private struct IntegerAccumulator { + private(set) var value = 0 + private(set) var sawValue = false + private(set) var overflowed = false + + mutating func add(_ value: Int?) { + guard let value else { return } + self.sawValue = true + guard !self.overflowed else { return } + let result = self.value.addingReportingOverflow(value) + if result.overflow { + self.overflowed = true + } else { + self.value = result.partialValue + } + } + + mutating func markOverflow() { + self.sawValue = true + self.overflowed = true + } + + var result: Int? { + self.sawValue && !self.overflowed ? self.value : nil + } + } + private struct BreakdownKey: Hashable { let modelName: String let attribution: CostUsageAttribution? } private struct BreakdownAccumulator { - var totalTokens: Int = 0 - var sawTotalTokens = false - var requestCount: Int = 0 - var sawRequestCount = false - var inputTokens: Int = 0 - var sawInputTokens = false - var outputTokens: Int = 0 - var sawOutputTokens = false - var cacheReadTokens: Int = 0 - var sawCacheReadTokens = false - var cacheCreationTokens: Int = 0 - var sawCacheCreationTokens = false - var reasoningTokens: Int = 0 - var sawReasoningTokens = false + var totalTokens = IntegerAccumulator() + var requestCount = IntegerAccumulator() + var inputTokens = IntegerAccumulator() + var outputTokens = IntegerAccumulator() + var cacheReadTokens = IntegerAccumulator() + var cacheCreationTokens = IntegerAccumulator() + var reasoningTokens = IntegerAccumulator() var costUSD: Double = 0 var sawCost = false var standardCostUSD: Double = 0 var sawStandardCost = false var priorityCostUSD: Double = 0 var sawPriorityCost = false - var standardTokens: Int = 0 - var sawStandardTokens = false - var priorityTokens: Int = 0 - var sawPriorityTokens = false + var standardTokens = IntegerAccumulator() + var priorityTokens = IntegerAccumulator() mutating func add(_ breakdown: ModelBreakdown) { - if let totalTokens = breakdown.totalTokens { - self.totalTokens += totalTokens - self.sawTotalTokens = true - } - if let requestCount = breakdown.requestCount { - self.requestCount += requestCount - self.sawRequestCount = true - } - if let inputTokens = breakdown.inputTokens { - self.inputTokens += inputTokens - self.sawInputTokens = true - } - if let outputTokens = breakdown.outputTokens { - self.outputTokens += outputTokens - self.sawOutputTokens = true - } - if let cacheReadTokens = breakdown.cacheReadTokens { - self.cacheReadTokens += cacheReadTokens - self.sawCacheReadTokens = true - } - if let cacheCreationTokens = breakdown.cacheCreationTokens { - self.cacheCreationTokens += cacheCreationTokens - self.sawCacheCreationTokens = true - } - if let reasoningTokens = breakdown.reasoningTokens { - self.reasoningTokens += reasoningTokens - self.sawReasoningTokens = true - } + self.totalTokens.add(breakdown.totalTokens) + self.requestCount.add(breakdown.requestCount) + self.inputTokens.add(breakdown.inputTokens) + self.outputTokens.add(breakdown.outputTokens) + self.cacheReadTokens.add(breakdown.cacheReadTokens) + self.cacheCreationTokens.add(breakdown.cacheCreationTokens) + self.reasoningTokens.add(breakdown.reasoningTokens) if let costUSD = breakdown.costUSD { self.costUSD += costUSD self.sawCost = true @@ -892,113 +889,74 @@ extension CostUsageDailyReport { self.priorityCostUSD += priorityCostUSD self.sawPriorityCost = true } - if let standardTokens = breakdown.standardTokens { - self.standardTokens += standardTokens - self.sawStandardTokens = true - } - if let priorityTokens = breakdown.priorityTokens { - self.priorityTokens += priorityTokens - self.sawPriorityTokens = true - } + self.standardTokens.add(breakdown.standardTokens) + self.priorityTokens.add(breakdown.priorityTokens) } func build(key: BreakdownKey) -> ModelBreakdown { ModelBreakdown( modelName: key.modelName, costUSD: self.sawCost ? self.costUSD : nil, - totalTokens: self.sawTotalTokens ? self.totalTokens : nil, - requestCount: self.sawRequestCount ? self.requestCount : nil, - inputTokens: self.sawInputTokens ? self.inputTokens : nil, - outputTokens: self.sawOutputTokens ? self.outputTokens : nil, - cacheReadTokens: self.sawCacheReadTokens ? self.cacheReadTokens : nil, - cacheCreationTokens: self.sawCacheCreationTokens ? self.cacheCreationTokens : nil, - reasoningTokens: self.sawReasoningTokens ? self.reasoningTokens : nil, + totalTokens: self.totalTokens.result, + requestCount: self.requestCount.result, + inputTokens: self.inputTokens.result, + outputTokens: self.outputTokens.result, + cacheReadTokens: self.cacheReadTokens.result, + cacheCreationTokens: self.cacheCreationTokens.result, + reasoningTokens: self.reasoningTokens.result, standardCostUSD: self.sawStandardCost ? self.standardCostUSD : nil, priorityCostUSD: self.sawPriorityCost ? self.priorityCostUSD : nil, - standardTokens: self.sawStandardTokens ? self.standardTokens : nil, - priorityTokens: self.sawPriorityTokens ? self.priorityTokens : nil, + standardTokens: self.standardTokens.result, + priorityTokens: self.priorityTokens.result, attribution: key.attribution) } } private struct EntryAccumulator { - var inputTokens: Int = 0 - var sawInputTokens = false - var cacheReadTokens: Int = 0 - var sawCacheReadTokens = false - var cacheCreationTokens: Int = 0 - var sawCacheCreationTokens = false - var outputTokens: Int = 0 - var sawOutputTokens = false - var reasoningTokens: Int = 0 - var sawReasoningTokens = false - var totalTokens: Int = 0 - var sawTotalTokens = false - var derivedTotalTokensWithoutExplicitTotal: Int = 0 - var requestCount: Int = 0 - var sawRequestCount = false + var inputTokens = IntegerAccumulator() + var cacheReadTokens = IntegerAccumulator() + var cacheCreationTokens = IntegerAccumulator() + var outputTokens = IntegerAccumulator() + var reasoningTokens = IntegerAccumulator() + var totalTokens = IntegerAccumulator() + var derivedTotalTokensWithoutExplicitTotal = IntegerAccumulator() + var requestCount = IntegerAccumulator() var costUSD: Double = 0 var sawCost = false - var unpricedRequestCount: Int = 0 - var sawUnpricedRequestCount = false - var unmeteredRequestCount: Int = 0 - var sawUnmeteredRequestCount = false - var estimatedRequestCount: Int = 0 - var sawEstimatedRequestCount = false + var unpricedRequestCount = IntegerAccumulator() + var unmeteredRequestCount = IntegerAccumulator() + var estimatedRequestCount = IntegerAccumulator() var modelsUsed: Set = [] var breakdowns: [BreakdownKey: BreakdownAccumulator] = [:] mutating func add(_ entry: Entry) { - let entryDerivedTotalTokens = (entry.inputTokens ?? 0) - + (entry.cacheReadTokens ?? 0) - + (entry.cacheCreationTokens ?? 0) - + (entry.outputTokens ?? 0) - if let inputTokens = entry.inputTokens { - self.inputTokens += inputTokens - self.sawInputTokens = true - } - if let cacheReadTokens = entry.cacheReadTokens { - self.cacheReadTokens += cacheReadTokens - self.sawCacheReadTokens = true - } - if let cacheCreationTokens = entry.cacheCreationTokens { - self.cacheCreationTokens += cacheCreationTokens - self.sawCacheCreationTokens = true - } - if let outputTokens = entry.outputTokens { - self.outputTokens += outputTokens - self.sawOutputTokens = true - } - if let reasoningTokens = entry.reasoningTokens { - self.reasoningTokens += reasoningTokens - self.sawReasoningTokens = true - } + self.inputTokens.add(entry.inputTokens) + self.cacheReadTokens.add(entry.cacheReadTokens) + self.cacheCreationTokens.add(entry.cacheCreationTokens) + self.outputTokens.add(entry.outputTokens) + self.reasoningTokens.add(entry.reasoningTokens) if let totalTokens = entry.totalTokens { - self.totalTokens += totalTokens - self.sawTotalTokens = true - } else if entryDerivedTotalTokens > 0 { - self.derivedTotalTokensWithoutExplicitTotal += entryDerivedTotalTokens - } - if let requestCount = entry.requestCount { - self.requestCount += requestCount - self.sawRequestCount = true + self.totalTokens.add(totalTokens) + } else { + var derivedTotalTokens = IntegerAccumulator() + derivedTotalTokens.add(entry.inputTokens) + derivedTotalTokens.add(entry.cacheReadTokens) + derivedTotalTokens.add(entry.cacheCreationTokens) + derivedTotalTokens.add(entry.outputTokens) + if derivedTotalTokens.overflowed { + self.derivedTotalTokensWithoutExplicitTotal.markOverflow() + } else if let value = derivedTotalTokens.result, value > 0 { + self.derivedTotalTokensWithoutExplicitTotal.add(value) + } } + self.requestCount.add(entry.requestCount) if let costUSD = entry.costUSD { self.costUSD += costUSD self.sawCost = true } - if let unpricedRequestCount = entry.unpricedRequestCount { - self.unpricedRequestCount += unpricedRequestCount - self.sawUnpricedRequestCount = true - } - if let unmeteredRequestCount = entry.unmeteredRequestCount { - self.unmeteredRequestCount += unmeteredRequestCount - self.sawUnmeteredRequestCount = true - } - if let estimatedRequestCount = entry.estimatedRequestCount { - self.estimatedRequestCount += estimatedRequestCount - self.sawEstimatedRequestCount = true - } + self.unpricedRequestCount.add(entry.unpricedRequestCount) + self.unmeteredRequestCount.add(entry.unmeteredRequestCount) + self.estimatedRequestCount.add(entry.estimatedRequestCount) if let modelsUsed = entry.modelsUsed { self.modelsUsed.formUnion(modelsUsed) } @@ -1016,13 +974,11 @@ extension CostUsageDailyReport { } func build(date: String) -> Entry { - let derivedTotalTokens = self.inputTokens - + self.cacheReadTokens - + self.cacheCreationTokens - + self.outputTokens - let totalTokens: Int? = if self.sawTotalTokens { - self.totalTokens + self.derivedTotalTokensWithoutExplicitTotal - } else if derivedTotalTokens > 0 { + let totalTokens: Int? = if self.totalTokens.sawValue { + Self.combinedResult(self.totalTokens, self.derivedTotalTokensWithoutExplicitTotal) + } else if let derivedTotalTokens = self.derivedTotalTokensWithoutExplicitTotal.result, + derivedTotalTokens > 0 + { derivedTotalTokens } else { nil @@ -1038,19 +994,30 @@ extension CostUsageDailyReport { let modelsUsed = self.modelsUsed.isEmpty ? nil : self.modelsUsed.sorted() return Entry( date: date, - inputTokens: self.sawInputTokens ? self.inputTokens : nil, - outputTokens: self.sawOutputTokens ? self.outputTokens : nil, - cacheReadTokens: self.sawCacheReadTokens ? self.cacheReadTokens : nil, - cacheCreationTokens: self.sawCacheCreationTokens ? self.cacheCreationTokens : nil, - reasoningTokens: self.sawReasoningTokens ? self.reasoningTokens : nil, + inputTokens: self.inputTokens.result, + outputTokens: self.outputTokens.result, + cacheReadTokens: self.cacheReadTokens.result, + cacheCreationTokens: self.cacheCreationTokens.result, + reasoningTokens: self.reasoningTokens.result, totalTokens: totalTokens, - requestCount: self.sawRequestCount ? self.requestCount : nil, + requestCount: self.requestCount.result, costUSD: self.sawCost ? self.costUSD : nil, modelsUsed: modelsUsed, modelBreakdowns: modelBreakdowns, - unpricedRequestCount: self.sawUnpricedRequestCount ? self.unpricedRequestCount : nil, - unmeteredRequestCount: self.sawUnmeteredRequestCount ? self.unmeteredRequestCount : nil, - estimatedRequestCount: self.sawEstimatedRequestCount ? self.estimatedRequestCount : nil) + unpricedRequestCount: self.unpricedRequestCount.result, + unmeteredRequestCount: self.unmeteredRequestCount.result, + estimatedRequestCount: self.estimatedRequestCount.result) + } + + private static func combinedResult( + _ lhs: IntegerAccumulator, + _ rhs: IntegerAccumulator) -> Int? + { + guard !lhs.overflowed, !rhs.overflowed else { return nil } + var combined = IntegerAccumulator() + combined.add(lhs.result) + combined.add(rhs.result) + return combined.result } } @@ -1083,46 +1050,22 @@ extension CostUsageDailyReport { } private static func mergedSummary(from entries: [Entry]) -> Summary { - var totalInputTokens = 0 - var sawTotalInputTokens = false - var totalOutputTokens = 0 - var sawTotalOutputTokens = false - var totalCacheReadTokens = 0 - var sawTotalCacheReadTokens = false - var totalCacheCreationTokens = 0 - var sawTotalCacheCreationTokens = false - var totalReasoningTokens = 0 - var sawTotalReasoningTokens = false - var totalTokens = 0 - var sawTotalTokens = false + var totalInputTokens = IntegerAccumulator() + var totalOutputTokens = IntegerAccumulator() + var totalCacheReadTokens = IntegerAccumulator() + var totalCacheCreationTokens = IntegerAccumulator() + var totalReasoningTokens = IntegerAccumulator() + var totalTokens = IntegerAccumulator() var totalCostUSD = 0.0 var sawTotalCostUSD = false for entry in entries { - if let inputTokens = entry.inputTokens { - totalInputTokens += inputTokens - sawTotalInputTokens = true - } - if let outputTokens = entry.outputTokens { - totalOutputTokens += outputTokens - sawTotalOutputTokens = true - } - if let cacheReadTokens = entry.cacheReadTokens { - totalCacheReadTokens += cacheReadTokens - sawTotalCacheReadTokens = true - } - if let cacheCreationTokens = entry.cacheCreationTokens { - totalCacheCreationTokens += cacheCreationTokens - sawTotalCacheCreationTokens = true - } - if let reasoningTokens = entry.reasoningTokens { - totalReasoningTokens += reasoningTokens - sawTotalReasoningTokens = true - } - if let entryTotalTokens = entry.totalTokens { - totalTokens += entryTotalTokens - sawTotalTokens = true - } + totalInputTokens.add(entry.inputTokens) + totalOutputTokens.add(entry.outputTokens) + totalCacheReadTokens.add(entry.cacheReadTokens) + totalCacheCreationTokens.add(entry.cacheCreationTokens) + totalReasoningTokens.add(entry.reasoningTokens) + totalTokens.add(entry.totalTokens) if let costUSD = entry.costUSD { totalCostUSD += costUSD sawTotalCostUSD = true @@ -1130,12 +1073,12 @@ extension CostUsageDailyReport { } return Summary( - totalInputTokens: sawTotalInputTokens ? totalInputTokens : nil, - totalOutputTokens: sawTotalOutputTokens ? totalOutputTokens : nil, - cacheReadTokens: sawTotalCacheReadTokens ? totalCacheReadTokens : nil, - cacheCreationTokens: sawTotalCacheCreationTokens ? totalCacheCreationTokens : nil, - reasoningTokens: sawTotalReasoningTokens ? totalReasoningTokens : nil, - totalTokens: sawTotalTokens ? totalTokens : nil, + totalInputTokens: totalInputTokens.result, + totalOutputTokens: totalOutputTokens.result, + cacheReadTokens: totalCacheReadTokens.result, + cacheCreationTokens: totalCacheCreationTokens.result, + reasoningTokens: totalReasoningTokens.result, + totalTokens: totalTokens.result, totalCostUSD: sawTotalCostUSD ? totalCostUSD : nil) } diff --git a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift index 5af765ca36..344905826a 100644 --- a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift +++ b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift @@ -502,4 +502,65 @@ struct CostUsageDailyReportMergeTests { #expect(merged.summary?.totalTokens == 120) #expect(abs((merged.data.first?.costUSD ?? 0) - 1.25) < 0.000001) } + + @Test + func `merged report rejects overflowing integer totals instead of trapping`() throws { + let first = CostUsageDailyReport( + data: [ + CostUsageDailyReport.Entry( + date: "2026-04-04", + inputTokens: Int.max, + outputTokens: Int.max, + totalTokens: Int.max, + requestCount: Int.max, + costUSD: 1, + modelsUsed: ["gpt-5.4"], + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.4", + costUSD: 1, + totalTokens: Int.max, + requestCount: Int.max, + inputTokens: Int.max, + outputTokens: Int.max), + ]), + ], + summary: nil) + let second = CostUsageDailyReport( + data: [ + CostUsageDailyReport.Entry( + date: "2026-04-04", + inputTokens: 1, + outputTokens: 1, + totalTokens: 1, + requestCount: 1, + costUSD: 1, + modelsUsed: ["gpt-5.4"], + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.4", + costUSD: 1, + totalTokens: 1, + requestCount: 1, + inputTokens: 1, + outputTokens: 1), + ]), + ], + summary: nil) + + let merged = CostUsageDailyReport.merged([first, second]) + let entry = try #require(merged.data.first) + let breakdown = try #require(entry.modelBreakdowns?.first) + #expect(entry.inputTokens == nil) + #expect(entry.outputTokens == nil) + #expect(entry.totalTokens == nil) + #expect(entry.requestCount == nil) + #expect(breakdown.inputTokens == nil) + #expect(breakdown.outputTokens == nil) + #expect(breakdown.totalTokens == nil) + #expect(breakdown.requestCount == nil) + #expect(merged.summary?.totalInputTokens == nil) + #expect(merged.summary?.totalOutputTokens == nil) + #expect(merged.summary?.totalTokens == nil) + } } From bee8a7e296d7fcd5df33403ad55086a69a8a2899 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 26 Aug 2026 13:19:59 -0700 Subject: [PATCH 148/183] Use upstream model in shared proxy stats --- Sources/CodexBar/ShareStatsPayload.swift | 10 ++++- Tests/CodexBarTests/ShareStatsTests.swift | 50 +++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/ShareStatsPayload.swift b/Sources/CodexBar/ShareStatsPayload.swift index 3860f94e79..2ab6a0839c 100644 --- a/Sources/CodexBar/ShareStatsPayload.swift +++ b/Sources/CodexBar/ShareStatsPayload.swift @@ -278,7 +278,7 @@ enum ShareStatsBuilder { }.flatMap { group in group.models.compactMap { row -> ShareStatsModelPayload? in let estimatedCost = self.finiteCost(row.totalCost) - guard let modelName = ShareStatsSanitizer.modelName(row.modelName), + guard let modelName = self.sharedModelName(for: row), let sharedProvider = self.sharedModelProvider(for: row), row.totalTokens != nil else { return nil } @@ -338,6 +338,14 @@ enum ShareStatsBuilder { return payload.hasShareableData ? payload : nil } + private static func sharedModelName(for row: SpendDashboardModel.ModelRow) -> String? { + guard row.attribution?.route == .cliProxyAPI else { + return ShareStatsSanitizer.modelName(row.modelName) + } + guard let upstreamModel = row.attribution?.upstream?.model else { return nil } + return ShareStatsSanitizer.modelName(upstreamModel) + } + private static func sharedModelProvider( for row: SpendDashboardModel.ModelRow) -> (provider: UsageProvider, name: String)? { diff --git a/Tests/CodexBarTests/ShareStatsTests.swift b/Tests/CodexBarTests/ShareStatsTests.swift index 76678ea9b9..e017a76f39 100644 --- a/Tests/CodexBarTests/ShareStatsTests.swift +++ b/Tests/CodexBarTests/ShareStatsTests.swift @@ -238,6 +238,56 @@ struct ShareStatsTests { #expect(payload.providers.first?.estimatedCost == 1.5) } + @Test + func `proxy share model uses the sanitized upstream model family`() throws { + let attribution = CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .anthropic, + upstream: .init( + provider: "codex", + authType: .oauth, + model: "gpt-5.4"), + evidence: [.cliProxyRequestLog, .cliProxyUsageTelemetry, .modelProvider]) + let group = SpendDashboardModel.CurrencyGroup( + currencyCode: "USD", + providers: [ + SpendDashboardModel.ProviderRow( + id: "codex", + rank: 1, + provider: .codex, + displayName: "Codex", + totalTokens: 42, + totalCost: 1.25, + coveredDayCount: 1), + ], + models: [ + SpendDashboardModel.ModelRow( + rank: 1, + provider: .claude, + providerName: "Claude", + modelName: "claude-sonnet-4", + totalTokens: 42, + totalCost: 1.25, + attribution: attribution), + ], + projects: [], + dailyPoints: [], + totalTokens: 42, + totalCost: 1.25, + coveredDayCount: 1, + chartDomain: Self.date...Self.date, + modelHistoryCompleteness: .complete) + + let payload = try #require(ShareStatsBuilder.make( + model: SpendDashboardModel(requestedDays: 1, groups: [group]))) + let sharedModel = try #require(payload.topModels.first) + + #expect(sharedModel.provider == .codex) + #expect(sharedModel.providerName == "Codex") + #expect(sharedModel.modelName == "GPT") + } + @Test func `cost only models do not enter token usage rankings`() throws { let model = SpendDashboardModel(requestedDays: 7, groups: [ From 19c92d149d37b2b1ef3376fdbd7e796f058df7d6 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 26 Aug 2026 13:40:10 -0700 Subject: [PATCH 149/183] Export upstream proxy model identity --- .../PreferencesSpendDashboardPane.swift | 35 +++++++++++--- .../SpendDashboardExportTests.swift | 47 +++++++++++++++++++ 2 files changed, 76 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index d073965b82..9f1b20fcbd 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -1379,6 +1379,7 @@ struct SpendDashboardExportPayload: Encodable, Sendable { let modelName: String let totalTokens: Int? let totalCost: Double? + let attribution: CostUsageAttribution? } static func make(model: SpendDashboardModel, hiddenSourceIDs: [String]) -> Self { @@ -1402,16 +1403,38 @@ struct SpendDashboardExportPayload: Encodable, Sendable { totalTokens: $0.totalTokens, totalCost: $0.totalCost) }, - models: group.models.map { - Model( - provider: $0.provider.rawValue, - modelName: $0.modelName, - totalTokens: $0.totalTokens, - totalCost: $0.totalCost) + models: group.models.map { row in + let identity = self.exportedModelIdentity(for: row) + return Model( + provider: identity.provider, + modelName: identity.modelName, + totalTokens: row.totalTokens, + totalCost: row.totalCost, + attribution: row.attribution) }) }, hiddenSourceIDs: hiddenSourceIDs) } + + private static func exportedModelIdentity( + for row: SpendDashboardModel.ModelRow) -> (provider: String, modelName: String) + { + guard row.attribution?.route == .cliProxyAPI, + let upstream = row.attribution?.upstream + else { + return (row.provider.rawValue, row.modelName) + } + let provider = upstream.provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let modelName = upstream.model?.trimmingCharacters(in: .whitespacesAndNewlines) + let exportedModelName = if let modelName, !modelName.isEmpty { + modelName + } else { + row.modelName + } + return ( + provider.isEmpty ? row.provider.rawValue : provider, + exportedModelName) + } } enum SpendDashboardJSONExporter { diff --git a/Tests/CodexBarTests/SpendDashboardExportTests.swift b/Tests/CodexBarTests/SpendDashboardExportTests.swift index 793c62dc69..698ab0d1e7 100644 --- a/Tests/CodexBarTests/SpendDashboardExportTests.swift +++ b/Tests/CodexBarTests/SpendDashboardExportTests.swift @@ -61,6 +61,53 @@ struct SpendDashboardExportTests { #expect(json.contains("\"provenance\"")) } + @Test + func `proxy model exports its upstream identity and route`() throws { + let now = Date(timeIntervalSince1970: 1_784_179_200) + let attribution = CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .anthropic, + upstream: .init( + provider: "Codex", + authType: .oauth, + model: " gpt-5.4 "), + evidence: [.cliProxyRequestLog, .cliProxyUsageTelemetry, .modelProvider]) + let group = SpendDashboardModel.CurrencyGroup( + currencyCode: "USD", + providers: [], + models: [ + SpendDashboardModel.ModelRow( + rank: 1, + provider: .claude, + providerName: "Claude", + modelName: "claude-sonnet-4", + totalTokens: 42, + totalCost: 1.25, + attribution: attribution), + ], + dailyPoints: [], + totalTokens: 42, + totalCost: 1.25, + coveredDayCount: 1, + chartDomain: now...now, + modelHistoryCompleteness: .complete) + let model = SpendDashboardModel(requestedDays: 1, groups: [group]) + + let data = try SpendDashboardJSONExporter.encodedData(model: model, hiddenSourceIDs: []) + let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let groups = try #require(object["groups"] as? [[String: Any]]) + let models = try #require(groups.first?["models"] as? [[String: Any]]) + let exported = try #require(models.first) + let exportedAttribution = try #require(exported["attribution"] as? [String: Any]) + let upstream = try #require(exportedAttribution["upstream"] as? [String: Any]) + + #expect(exported["provider"] as? String == "codex") + #expect(exported["modelName"] as? String == "gpt-5.4") + #expect(exportedAttribution["route"] as? String == "cliProxyAPI") + #expect(upstream["provider"] as? String == "Codex") + } + @MainActor @Test func `copy writes JSON to the pasteboard`() throws { From f322971a84b01a8696002cf21064002d0d04d0a9 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 26 Aug 2026 16:16:48 -0700 Subject: [PATCH 150/183] Preserve proxy model token details --- .../Vendored/CostUsage/CostUsageScanner+Claude.swift | 5 +++++ Tests/CodexBarTests/CostUsageScannerClaudeFableTests.swift | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 34cdd62cfa..6be9f3a115 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -1584,6 +1584,11 @@ extension CostUsageScanner { modelName: model, costUSD: cost, totalTokens: totalTokens, + requestCount: sampleCount, + inputTokens: input, + outputTokens: output, + cacheReadTokens: cacheRead, + cacheCreationTokens: cacheCreate, attribution: modelKey.attribution)) if let cost { dayCost += cost diff --git a/Tests/CodexBarTests/CostUsageScannerClaudeFableTests.swift b/Tests/CodexBarTests/CostUsageScannerClaudeFableTests.swift index ce9b5f25ef..46ad83561d 100644 --- a/Tests/CodexBarTests/CostUsageScannerClaudeFableTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerClaudeFableTests.swift @@ -171,6 +171,12 @@ struct CostUsageScannerClaudeFableTests { #expect(breakdown.attribution?.upstream?.provider == "google") #expect(breakdown.attribution?.upstream?.model == "gemini-test-pro") #expect(abs((breakdown.costUSD ?? 0) - expected) < 0.000000001) + #expect(breakdown.totalTokens == 135) + #expect(breakdown.requestCount == 1) + #expect(breakdown.inputTokens == 100) + #expect(breakdown.outputTokens == 5) + #expect(breakdown.cacheReadTokens == 20) + #expect(breakdown.cacheCreationTokens == 10) } @Test From dc52ee802870d5589985fa61646158364ef9e0dd Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 26 Aug 2026 16:32:23 -0700 Subject: [PATCH 151/183] Fix proxy custom cache isolation --- .../CLIProxyAPIUsageTelemetry.swift | 4 +- .../CLIProxyAPIUsageCollectorTests.swift | 43 ++++++++++++++++--- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index 8469baee01..dd0a607505 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -1246,7 +1246,7 @@ public enum CLIProxyAPIUsageCollector { -> CLIProxyAPIUsageCollectionResult { guard let settings, settings.isConfigured else { return .notConfigured } - let stateRoot = cacheRoot?.deletingLastPathComponent() + let stateRoot = cacheRoot let configurationGeneration = CostUsageCacheLocations.cliProxyAPIConfigurationGeneration(stateRoot: stateRoot) return await self.collect( cacheRoot: cacheRoot, @@ -1276,7 +1276,7 @@ public enum CLIProxyAPIUsageCollector { return await self.collectionGate.perform { do { return try await CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( - stateRoot: cacheRoot?.deletingLastPathComponent()) + stateRoot: cacheRoot) { guard configurationIsCurrent() else { return .notConfigured } return await self.collectUnserialized( diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift index d5bdd6d0ec..23227717e5 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift @@ -63,10 +63,12 @@ struct CLIProxyAPIUsageCollectorTests { func `persists an idless popped batch outside a failed cache for the next collection`() async throws { let fileManager = FileManager.default let cacheRoot = fileManager.temporaryDirectory - .appendingPathComponent("cliproxy-blocked-\(UUID().uuidString)", isDirectory: false) + .appendingPathComponent("cliproxy-blocked-\(UUID().uuidString)", isDirectory: true) + let blockedCostUsageRoot = cacheRoot.appendingPathComponent("cost-usage", isDirectory: false) let pendingRoot = fileManager.temporaryDirectory .appendingPathComponent("cliproxy-pending-\(UUID().uuidString)", isDirectory: true) - try Data("not-a-directory".utf8).write(to: cacheRoot) + try fileManager.createDirectory(at: cacheRoot, withIntermediateDirectories: true) + try Data("not-a-directory".utf8).write(to: blockedCostUsageRoot) defer { try? fileManager.removeItem(at: cacheRoot) try? fileManager.removeItem(at: pendingRoot) @@ -105,7 +107,7 @@ struct CLIProxyAPIUsageCollectorTests { let pendingOccurrenceID = try #require( CLIProxyAPIUsagePendingIO.load(pendingRoot: pendingRoot)?.first?.localOccurrenceID) #expect(!pendingOccurrenceID.isEmpty) - try fileManager.removeItem(at: cacheRoot) + try fileManager.removeItem(at: blockedCostUsageRoot) let retryClient = CLIProxyAPIUsageQueueClient( settings: .init(managementKey: "management-secret"), dataLoader: { request in @@ -231,7 +233,7 @@ struct CLIProxyAPIUsageCollectorTests { func `collector rechecks configuration after acquiring the interprocess lock`() async throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("cliproxy-queued-disconnect-\(UUID().uuidString)", isDirectory: true) - let cacheRoot = root.appendingPathComponent("cost-usage", isDirectory: true) + let cacheRoot = root defer { try? FileManager.default.removeItem(at: root) } let lockAcquired = DispatchSemaphore(value: 0) let releaseLock = DispatchSemaphore(value: 0) @@ -282,11 +284,42 @@ struct CLIProxyAPIUsageCollectorTests { #expect(result == .failed("CLIProxyAPI configuration is temporarily unavailable.")) } + @Test + func `custom cache root disconnect prevents destructive pop`() async { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cliproxy-custom-root-disconnect-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let settings = CLIProxyAPIConnectionSettings(managementKey: "management-secret") + #expect(CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected(true, stateRoot: root)) + let popProbe = CLIProxyAPICollectionContinuationProbe() + let client = CLIProxyAPIUsageQueueClient( + settings: settings, + dataLoader: { request in + await popProbe.recordPop() + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + return (Data("[]".utf8), response) + }) + + let result = await CLIProxyAPIUsageCollector.collect( + cacheRoot: root, + settings: settings, + currentSettingsResult: { .found(settings) }, + client: client) + + #expect(result == .notConfigured) + #expect(await popProbe.popCount == 0) + } + @Test func `temporary credential failure rejects a replaced configuration after lock acquisition`() async throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("cliproxy-replaced-configuration-\(UUID().uuidString)", isDirectory: true) - let cacheRoot = root.appendingPathComponent("cost-usage", isDirectory: true) + let cacheRoot = root defer { try? FileManager.default.removeItem(at: root) } let initialGeneration = try #require( CostUsageCacheLocations.prepareCLIProxyAPIConfigurationGenerationUpdate(stateRoot: root)) From 2fc3b50154ae1f16342e7f2271c5d3ffb802de7c Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 26 Aug 2026 16:57:56 -0700 Subject: [PATCH 152/183] Align proxy cache timing and locks --- .../CLIProxyAPIUsageTelemetry.swift | 22 +----- .../CLIProxyAPIUsageCacheLockTests.swift | 67 +++++++++++++++++++ .../CLIProxyAPIUsageCacheTests.swift | 8 +-- 3 files changed, 74 insertions(+), 23 deletions(-) create mode 100644 Tests/CodexBarTests/CLIProxyAPIUsageCacheLockTests.swift diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index dd0a607505..0e9338a23f 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -142,22 +142,6 @@ struct CLIProxyAPIUsageRecord: Codable, Equatable, Sendable { generate: self.generate, tokens: self.tokens) } - - func replacingTimestamp(_ timestamp: Date) -> Self { - Self( - timestamp: timestamp, - provider: self.provider, - executorType: self.executorType, - model: self.model, - alias: self.alias, - endpoint: self.endpoint, - authType: self.authType, - requestID: self.requestID, - localOccurrenceID: self.localOccurrenceID, - failed: self.failed, - generate: self.generate, - tokens: self.tokens) - } } private enum CLIProxyAPIUsageRetention { @@ -172,7 +156,7 @@ private enum CLIProxyAPIUsageRetention { let futureCutoff = now.addingTimeInterval(self.maximumFutureClockSkew) return records.compactMap { record in guard record.timestamp >= cutoff, record.timestamp <= futureCutoff else { return nil } - return record.timestamp > now ? record.replacingTimestamp(now) : record + return record } } } @@ -252,7 +236,7 @@ enum CLIProxyAPIUsageCacheIO { { do { return try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( - stateRoot: cacheRoot?.deletingLastPathComponent()) + stateRoot: cacheRoot) { self.withExclusiveAccess { guard let currentCache = self.loadCache( @@ -288,7 +272,7 @@ enum CLIProxyAPIUsageCacheIO { do { return try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( - stateRoot: cacheRoot?.deletingLastPathComponent()) + stateRoot: cacheRoot) { self.withExclusiveAccess { guard let currentCache = self.loadCache( diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheLockTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheLockTests.swift new file mode 100644 index 0000000000..0df513b6a6 --- /dev/null +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheLockTests.swift @@ -0,0 +1,67 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CLIProxyAPIUsageCacheLockTests { + @Test + func `pruning waits for the custom root interprocess lock`() async throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-pruning-lock-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: root) } + let timestamp = try #require(CostUsageDateParser.parse("2026-07-16T12:00:00Z")) + let record = CLIProxyAPIUsageRecord( + timestamp: timestamp, + provider: "codex", + model: "gpt-5.5", + alias: "gpt-5.5", + endpoint: "POST /v1/messages", + authType: "oauth", + requestID: "expired", + tokens: .init(input: 10, output: 20, total: 30)) + #expect(CLIProxyAPIUsageCacheIO.merge( + [record], + cacheRoot: root, + now: timestamp) == 1) + let lockAcquired = DispatchSemaphore(value: 0) + let releaseLock = DispatchSemaphore(value: 0) + let loadStarted = DispatchSemaphore(value: 0) + let loadFinished = DispatchSemaphore(value: 0) + let lockHolder = Task.detached { + try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock(stateRoot: root) { + lockAcquired.signal() + releaseLock.wait() + } + } + #expect(await Self.waitForSignal(lockAcquired, timeout: .now() + 1)) + + let loadTask = Task.detached { + loadStarted.signal() + let records = CLIProxyAPIUsageCacheIO.load( + cacheRoot: root, + now: timestamp.addingTimeInterval(367 * 24 * 60 * 60)) + loadFinished.signal() + return records + } + #expect(await Self.waitForSignal(loadStarted, timeout: .now() + 1)) + let loadFinishedBeforeRelease = await Self.waitForSignal( + loadFinished, + timeout: .now() + .milliseconds(50)) + #expect(!loadFinishedBeforeRelease) + + releaseLock.signal() + try await lockHolder.value + #expect(await loadTask.value.isEmpty) + } + + private static func waitForSignal( + _ semaphore: DispatchSemaphore, + timeout: DispatchTime) async -> Bool + { + await withCheckedContinuation { continuation in + DispatchQueue.global().async { + continuation.resume(returning: semaphore.wait(timeout: timeout) == .success) + } + } + } +} diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index 5721c4dd52..b7b7f552f7 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -668,7 +668,7 @@ extension CLIProxyAPIUsageCacheTests { let loadStarted = DispatchSemaphore(value: 0) let loadFinished = DispatchSemaphore(value: 0) let lockHolder = Task.detached { - try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock(stateRoot: root) { + try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock(stateRoot: durableRoot) { lockAcquired.signal() releaseLock.wait() #expect(CLIProxyAPIUsageCacheIO.merge( @@ -993,7 +993,7 @@ extension CLIProxyAPIUsageCacheTests { } @Test - func `usage retention clamps clock skew and rejects implausible future records`() throws { + func `usage retention preserves tolerated clock skew and rejects implausible future records`() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory .appendingPathComponent("cliproxy-future-retention-\(UUID().uuidString)", isDirectory: true) @@ -1008,12 +1008,12 @@ extension CLIProxyAPIUsageCacheTests { #expect(CLIProxyAPIUsageCacheIO.merge(records, cacheRoot: root, now: now) == 2) let cached = CLIProxyAPIUsageCacheIO.load(cacheRoot: root, now: now) #expect(cached.map(\.requestID) == ["retained", "clock-skew"]) - #expect(cached.last?.timestamp == now) + #expect(cached.last?.timestamp == now.addingTimeInterval(60)) #expect(CLIProxyAPIUsagePendingIO.save(records, pendingRoot: root)) let pending = try #require(CLIProxyAPIUsagePendingIO.load(pendingRoot: root, now: now)) #expect(pending.map(\.requestID) == ["retained", "clock-skew"]) - #expect(pending.last?.timestamp == now) + #expect(pending.last?.timestamp == now.addingTimeInterval(60)) #expect(CLIProxyAPIUsagePendingIO.load(pendingRoot: root, now: now) == pending) } From a0effddbde142d39755f8707ca4ef08612d4f16b Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 26 Aug 2026 17:47:10 -0700 Subject: [PATCH 153/183] Update merged Claude token expectation --- Tests/CodexBarTests/CostUsageFetcherTests.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index e023fcc4bf..4544ec0857 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -839,7 +839,12 @@ extension CostUsageFetcherTests { CostUsageDailyReport.ModelBreakdown( modelName: "claude-sonnet-4-6", costUSD: nativeCost + piCost, - totalTokens: 205), + totalTokens: 205, + requestCount: 1, + inputTokens: 100, + outputTokens: 20, + cacheReadTokens: 5, + cacheCreationTokens: 10), ]) } From ea354bc6bd57eb603745634276184f59c5c4e6a0 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 26 Aug 2026 18:47:11 -0700 Subject: [PATCH 154/183] Fix proxy overview accounting --- .../CodexBar/SpendDashboardController.swift | 7 ++- .../CodexBar/SpendDashboardPublication.swift | 7 ++- .../CostUsageScannerBreakdownTests.swift | 7 ++- .../SpendDashboardCodexProxySourceTests.swift | 54 +++++++++++++++++++ 4 files changed, 72 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index 4dbe1453c0..6a65d4f9c0 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -1792,11 +1792,16 @@ final class SpendDashboardController { } else { .unavailable } + let role: SpendSourcePublication.Role = switch input?.sourceKind { + case .openCodex: .enrichment + case .cliProxyAPI: .supplemental + case .native, nil: .subscription + } return SpendSourcePublication( id: sourceID, provider: provider, displayName: input?.displayName ?? self.displayName(for: sourceID, provider: provider), - role: input?.sourceKind == .openCodex ? .enrichment : .subscription, + role: role, state: state) } if self.configuration?.openCodexUsageLogsEnabled == true, diff --git a/Sources/CodexBar/SpendDashboardPublication.swift b/Sources/CodexBar/SpendDashboardPublication.swift index 087f27218b..a844c5d27b 100644 --- a/Sources/CodexBar/SpendDashboardPublication.swift +++ b/Sources/CodexBar/SpendDashboardPublication.swift @@ -5,6 +5,7 @@ struct SpendSourcePublication: Sendable, Equatable { enum Role: Sendable, Equatable { case subscription case enrichment + case supplemental } enum State: Sendable, Equatable { @@ -54,7 +55,11 @@ struct SpendDashboardPublication: Sendable { source.state == .staleLastKnown ? source.id : nil }) let inputs = self.inputs.filter { input in - (providerScope?.contains(input.provider) ?? true) && !staleSourceIDs.contains(input.id) + let isInProviderScope = providerScope.map { scope in + scope.contains(input.provider) || + (input.sourceKind == .cliProxyAPI && scope.contains(.claude)) + } ?? true + return isInProviderScope && !staleSourceIDs.contains(input.id) } return SpendDashboardModel.build( inputs: inputs, diff --git a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift index 7fba070778..8caae7afb5 100644 --- a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift @@ -6538,7 +6538,12 @@ struct CostUsageScannerBreakdownTests { CostUsageDailyReport.ModelBreakdown( modelName: "claude-sonnet-4-20250514", costUSD: report.data[0].costUSD, - totalTokens: 355), + totalTokens: 355, + requestCount: 1, + inputTokens: 200, + outputTokens: 80, + cacheReadTokens: 25, + cacheCreationTokens: 50), ]) #expect((report.data[0].costUSD ?? 0) > 0) } diff --git a/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift b/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift index da382d51cf..6a0b7f506c 100644 --- a/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift +++ b/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift @@ -81,6 +81,60 @@ struct SpendDashboardCodexProxySourceTests { #expect(result.inputs.map(\.id) == [SpendDashboardSource.codexProxySourceID]) } + @Test + func `proxy usage supplements claude overview without adding a subscription`() async throws { + let now = Date(timeIntervalSince1970: 1_784_179_200) + let configuration = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.claude.rawValue], + codexAccountIdentities: []) + let request = SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + now: now, + force: false) + let proxyInput = SpendDashboardModel.ProviderInput( + id: SpendDashboardSource.codexProxySourceID, + provider: .codex, + displayName: "Codex · CLIProxyAPI", + snapshot: Self.snapshot(cost: 2, now: now), + sourceKind: .cliProxyAPI) + let controller = SpendDashboardController( + requestBuilder: { _ in request }, + loader: { _ in SpendDashboardLoadResult(inputs: [proxyInput], failedSourceIDs: []) }) + + controller.update(configuration: configuration) + let deadline = Date().addingTimeInterval(2) + while controller.isRefreshing, Date() < deadline { + try await Task.sleep(for: .milliseconds(2)) + } + + let publication = controller.publication + let proxySource = try #require(publication.sources.first { + $0.id == SpendDashboardSource.codexProxySourceID + }) + let claudeOnly = publication.model( + requestedDays: 7, + now: now, + calendar: .current, + preferredCurrencyCode: "USD", + providerScope: [.claude]) + let combined = publication.model( + requestedDays: 7, + now: now, + calendar: .current, + preferredCurrencyCode: "USD", + providerScope: [.claude, .codex]) + + #expect(!controller.isRefreshing) + #expect(proxySource.role == .supplemental) + #expect(claudeOnly.groups.flatMap(\.providers).map(\.id) == [SpendDashboardSource.codexProxySourceID]) + #expect(combined.groups.flatMap(\.providers).count { $0.id == SpendDashboardSource.codexProxySourceID } == 1) + #expect(publication.subscriptionCount(providerScope: [.claude]) == 1) + } + @Test func `proxy usage stays visible when OpenCodex hides native Codex`() { let now = Date(timeIntervalSince1970: 1_784_179_200) From 1987b3dd4a122b1c88c1f4509f43a604e859172c Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 26 Aug 2026 19:32:03 -0700 Subject: [PATCH 155/183] Refresh provider architecture anchors --- Sources/CodexBar/SpendDashboardPublication.swift | 1 + .../ProviderArchitectureGatekeeperTests.swift | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBar/SpendDashboardPublication.swift b/Sources/CodexBar/SpendDashboardPublication.swift index a844c5d27b..49d9118926 100644 --- a/Sources/CodexBar/SpendDashboardPublication.swift +++ b/Sources/CodexBar/SpendDashboardPublication.swift @@ -55,6 +55,7 @@ struct SpendDashboardPublication: Sendable { source.state == .staleLastKnown ? source.id : nil }) let inputs = self.inputs.filter { input in + // Provider-specific by design: CLIProxyAPI attribution supplements Claude usage scopes. let isInProviderScope = providerScope.map { scope in scope.contains(input.provider) || (input.sourceKind == .cliProxyAPI && scope.contains(.claude)) diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 40c2abf43c..31149bdb8c 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -933,19 +933,19 @@ struct ProviderArchitectureGatekeeperTests { reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 1818, + line: 1823, 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: 1847, + line: 1852, 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: 1864, + line: 1869, anchor: "if sourceID.hasPrefix(\"codex:\") { return .codex }", expectedProviderIDs: ["codex"], reason: "This publication projection maps stable Codex account source IDs back to their provider family."), @@ -2399,7 +2399,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: 1891, + line: 1896, anchor: "guard input.provider == .codex,", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, From 59e6c7661f56f817e8f9537d56ec4588303f6d41 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 26 Aug 2026 19:40:01 -0700 Subject: [PATCH 156/183] Stabilize provider architecture markers --- .../CodexBar/SpendDashboardController.swift | 2 ++ .../ProviderArchitectureGatekeeperTests.swift | 26 ------------------- 2 files changed, 2 insertions(+), 26 deletions(-) diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index 6a65d4f9c0..29a80ee320 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -1849,6 +1849,7 @@ final class SpendDashboardController { { var ids: [String] = [] for providerID in self.configuration?.providerIDs ?? [] { + // Provider-specific by design: Codex account sources use stable identity-derived IDs. if providerID == UsageProvider.codex.rawValue { ids.append(contentsOf: (self.configuration?.codexAccountIdentities ?? []).compactMap { identity in guard let separator = identity.lastIndex(of: "|") else { return nil } @@ -1866,6 +1867,7 @@ final class SpendDashboardController { } private func provider(for sourceID: String) -> UsageProvider? { + // Provider-specific by design: Codex account source IDs map back to their provider family. if sourceID.hasPrefix("codex:") { return .codex } return UsageProvider(rawValue: sourceID) } diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 31149bdb8c..d9937026c9 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -931,24 +931,6 @@ struct ProviderArchitectureGatekeeperTests { 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: 1823, - 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: 1852, - 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: 1869, - anchor: "if sourceID.hasPrefix(\"codex:\") { return .codex }", - expectedProviderIDs: ["codex"], - reason: "This publication projection maps stable Codex account source IDs back to their provider family."), SuppressedProviderReference( path: "Sources/CodexBar/StatusItemController+CodexStackedMenu.swift", line: 26, @@ -2397,14 +2379,6 @@ struct ProviderArchitectureGatekeeperTests { expectedReferenceCount: 3, expectedReferenceFingerprint: ["codex@0", "codex@2", "codex@9"], reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), - AllowedProviderConstruct( - path: "Sources/CodexBar/SpendDashboardController.swift", - line: 1896, - anchor: "guard input.provider == .codex,", - expectedProviderIDs: ["codex"], - expectedReferenceCount: 1, - expectedReferenceFingerprint: ["codex@0"], - reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardModel+ModelBreakdown.swift", line: 132, From 3813aa0d72b7bc9f9953cb0a91f4be3db1999698 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 26 Aug 2026 19:40:43 -0700 Subject: [PATCH 157/183] Recognize proxy telemetry without request logs --- CHANGELOG.md | 6 + .../CLIProxyAPIAttributionResolver.swift | 155 ++++++++++++------ .../CostUsage/CostUsageScanner+Claude.swift | 15 +- .../CLIProxyAPIAttributionResolverTests.swift | 136 +++++++++++++++ 4 files changed, 254 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23419eaa4f..61d0423d76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## 0.55.2 — Unreleased +### Fixed +- CLIProxyAPI attribution: identify Claude Code requests from uniquely matching management-queue telemetry even when CLIProxyAPI request-file logging is disabled, so routed Codex models display their upstream and `CLIProxyAPI via Claude Code` provenance. +- Antigravity: select the most constrained known quota independently for session and weekly menu-bar layout tokens, so unused model families no longer mask consumed quota (#3206). Thanks @foobra! +- OpenCode Go: preserve API percentage units so 1% usage no longer appears as 100%, including local-history overlays (#3216). Thanks @rodrigoalma! +- CLI install: block inherited shell functions and startup hooks before helper validation and failure handling, and use absolute tools and a clean administrator-command environment while retaining approval and both existing symlink destinations (#3205, #3217). + ## 0.55.1 — 2026-08-25 ### Highlights diff --git a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift index 68a0003b24..aa1c500069 100644 --- a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift +++ b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift @@ -33,6 +33,23 @@ struct CLIProxyAPIAttributionResolver: Sendable { let sessionID: String? let timestampUnixMs: Int64? let tokens: TokenSignature? + let occurrenceID: String? + + init( + model: String, + modelProvider: CostUsageAttribution.ModelProvider, + sessionID: String?, + timestampUnixMs: Int64?, + tokens: TokenSignature?, + occurrenceID: String? = nil) + { + self.model = model + self.modelProvider = modelProvider + self.sessionID = sessionID + self.timestampUnixMs = timestampUnixMs + self.tokens = tokens + self.occurrenceID = occurrenceID + } } private struct ObservationKey: Hashable { @@ -61,6 +78,7 @@ struct CLIProxyAPIAttributionResolver: Sendable { private static let maxLogPrefixBytes = 2 * 1024 * 1024 private static let maximumRouteMatchDistance: TimeInterval = 60 * 60 private static let maximumTelemetryMatchDistance: TimeInterval = 5 + private static let maximumTelemetryOnlyMatchDistance: TimeInterval = 30 private static let observationCache = ObservationCache() private let observationsBySessionID: [String: [Observation]] @@ -175,9 +193,12 @@ struct CLIProxyAPIAttributionResolver: Sendable { tokens: request.tokens) } let matchKeys = Set(observationMatches.map(\.key)) - let usageRecordMatch = observationMatches.count == observationCandidates.count && matchKeys.count == 1 + let observationUsageRecordMatch = observationMatches.count == observationCandidates.count + && matchKeys.count == 1 ? observationMatches.first : nil + let usageRecordMatch = observationUsageRecordMatch + ?? (observationCandidates.isEmpty ? self.closestUsageRecordMatch(request: request) : nil) let observation = observationCandidates.count == 1 ? observationCandidates[0] : nil return ( request: request, @@ -207,9 +228,13 @@ struct CLIProxyAPIAttributionResolver: Sendable { routeCandidatesByObservation[Self.observationKey(observation), default: []].append( (index: index, request: item.request, observation: observation)) } - let matchCounts = Dictionary( - grouping: prepared.compactMap(\.usageRecordMatch?.key), - by: { $0 }).mapValues(\.count) + var matchClaimers: [UsageRecordKey: Set] = [:] + for (index, item) in prepared.enumerated() { + guard let key = item.usageRecordMatch?.key else { continue } + let claimer = item.request.occurrenceID.map { "occurrence:\($0)" } ?? "index:\(index)" + matchClaimers[key, default: []].insert(claimer) + } + let matchCounts = matchClaimers.mapValues(\.count) var routeOwnerByObservation: [ObservationKey: Int] = [:] for (key, candidates) in routeCandidatesByObservation { if candidates.count == 1, @@ -376,47 +401,6 @@ struct CLIProxyAPIAttributionResolver: Sendable { : nil } - private func closestUsageRecordMatch( - observation: Observation, - model: String, - tokens: TokenSignature?) -> UsageRecordMatch? - { - guard let observationTimestamp = observation.timestamp else { return nil } - let candidates = self.usageRecordMatches( - observation: observation, - model: model, - tokens: tokens) - return Self.uniqueClosest( - candidates, - target: observationTimestamp, - timestamp: { $0.record.timestamp }) - } - - private func usageRecordMatches( - observation: Observation, - model: String, - tokens: TokenSignature?) -> [UsageRecordMatch] - { - guard let observationTimestamp = observation.timestamp else { return [] } - let canonicalModel = Self.canonicalModel(model) - guard let records = self.usageRecordsByCanonicalModel[canonicalModel] else { return [] } - let earliest = observationTimestamp.addingTimeInterval(-Self.maximumTelemetryMatchDistance) - let latest = observationTimestamp.addingTimeInterval(Self.maximumTelemetryMatchDistance) - let startIndex = Self.firstRecordIndex(atOrAfter: earliest, in: records) - var candidates: [UsageRecordMatch] = [] - for index in startIndex.. [Observation] @@ -434,8 +418,8 @@ struct CLIProxyAPIAttributionResolver: Sendable { representedObservations: Set) -> Bool { let plausibleKeys = self.plausibleObservations(for: record, model: model).map(Self.observationKey) - return !plausibleKeys.isEmpty - && Set(plausibleKeys).count == plausibleKeys.count + guard !plausibleKeys.isEmpty else { return true } + return Set(plausibleKeys).count == plausibleKeys.count && plausibleKeys.allSatisfy(representedObservations.contains) } @@ -868,20 +852,87 @@ struct CLIProxyAPIAttributionResolver: Sendable { } extension CLIProxyAPIAttributionResolver { + private func closestUsageRecordMatch( + observation: Observation, + model: String, + tokens: TokenSignature?) -> UsageRecordMatch? + { + guard let observationTimestamp = observation.timestamp else { return nil } + let candidates = self.usageRecordMatches( + observation: observation, + model: model, + tokens: tokens) + return Self.uniqueClosest( + candidates, + target: observationTimestamp, + timestamp: { $0.record.timestamp }) + } + + private func closestUsageRecordMatch(request: Request) -> UsageRecordMatch? { + guard let timestamp = Self.timestamp(for: request), let tokens = request.tokens else { return nil } + let candidates = self.usageRecordMatches( + model: request.model, + timestamp: timestamp, + tokens: tokens, + maximumDistance: Self.maximumTelemetryOnlyMatchDistance) + return Self.uniqueClosest( + candidates, + target: timestamp, + timestamp: { $0.record.timestamp }) + } + + private func usageRecordMatches( + observation: Observation, + model: String, + tokens: TokenSignature?) -> [UsageRecordMatch] + { + guard let observationTimestamp = observation.timestamp else { return [] } + return self.usageRecordMatches( + model: model, + timestamp: observationTimestamp, + tokens: tokens, + maximumDistance: Self.maximumTelemetryMatchDistance) + } + + private func usageRecordMatches( + model: String, + timestamp: Date, + tokens: TokenSignature?, + maximumDistance: TimeInterval) -> [UsageRecordMatch] + { + let canonicalModel = Self.canonicalModel(model) + guard let records = self.usageRecordsByCanonicalModel[canonicalModel] else { return [] } + let earliest = timestamp.addingTimeInterval(-maximumDistance) + let latest = timestamp.addingTimeInterval(maximumDistance) + let startIndex = Self.firstRecordIndex(atOrAfter: earliest, in: records) + var candidates: [UsageRecordMatch] = [] + for index in startIndex.. Bool { guard telemetry.output == tokens.output else { return false } - if telemetry.cacheRead != 0 || telemetry.cacheCreation != 0 { - return telemetry.input == tokens.input - && telemetry.cacheRead == tokens.cacheRead - && telemetry.cacheCreation == tokens.cacheCreate - } let (inputAndCacheRead, inputAndCacheReadOverflow) = tokens.input.addingReportingOverflow(tokens.cacheRead) guard !inputAndCacheReadOverflow else { return false } let (claudeInputTotal, claudeInputTotalOverflow) = inputAndCacheRead.addingReportingOverflow(tokens.cacheCreate) guard !claudeInputTotalOverflow else { return false } + if telemetry.cacheRead != 0 || telemetry.cacheCreation != 0 { + return telemetry.cacheRead == tokens.cacheRead + && telemetry.cacheCreation == tokens.cacheCreate + && (telemetry.input == tokens.input || telemetry.input == claudeInputTotal) + } let (telemetryInputTotal, telemetryInputTotalOverflow) = telemetry.input .addingReportingOverflow(telemetry.cached) return telemetry.input == tokens.input diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 6be9f3a115..3f94a90def 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -567,7 +567,8 @@ extension CostUsageScanner { input: row.input, cacheRead: row.cacheRead, cacheCreate: row.cacheCreate, - output: row.output)), + output: row.output), + occurrenceID: row.messageId), modelProvider: modelProvider, cachedAttribution: row.attribution) } @@ -575,11 +576,12 @@ extension CostUsageScanner { let liveAttributions = attributionResolver.attributions(for: requests) var replacementKeys: Set = [] var replacements: [ClaudeAttributionReconciliationKey: CostUsageAttribution] = [:] - for (index, item) in items.enumerated() - where attributionResolver.hasMatchingObservation(for: item.request) - { - replacementKeys.insert(item.key) + for (index, item) in items.enumerated() { let liveAttribution = liveAttributions[index] + guard liveAttribution.route == .cliProxyAPI + || attributionResolver.hasMatchingObservation(for: item.request) + else { continue } + replacementKeys.insert(item.key) let replacement: CostUsageAttribution? = if liveAttribution.route == .cliProxyAPI { Self.preferredCLIProxyAPIAttribution( live: liveAttribution, @@ -1320,7 +1322,8 @@ extension CostUsageScanner { input: item.row.input, cacheRead: item.row.cacheRead, cacheCreate: item.row.cacheCreate, - output: item.row.output)) + output: item.row.output), + occurrenceID: item.row.messageId) } let liveAttributions: [CostUsageAttribution?] = if let resolver = attributionContext.resolver { resolver.attributions(for: requests).map(Optional.some) diff --git a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift index 047719eee1..6ddec789c1 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift @@ -254,6 +254,142 @@ extension CLIProxyAPIAttributionResolverTests { #expect(attribution.upstream?.displayName == "OpenRouter API key") } + @Test + func `unique telemetry identifies routed request without request file logging`() throws { + let timestamp = Date(timeIntervalSince1970: 1_784_179_200) + let resolver = CLIProxyAPIAttributionResolver( + observations: [], + usageRecords: [ + Self.record( + timestamp: timestamp.addingTimeInterval(-12), + provider: "codex", + authType: "oauth"), + ]) + let request = CLIProxyAPIAttributionResolver.Request( + model: "gpt-5.5", + modelProvider: .openAI, + sessionID: "session-1", + timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), + tokens: Self.tokens) + + let attribution = try #require(resolver.attributions(for: [request]).first) + + #expect(attribution.route == .cliProxyAPI) + #expect(attribution.upstream?.provider == "codex") + #expect(attribution.upstream?.authType == .oauth) + #expect(attribution.evidence == [.cliProxyUsageTelemetry, .modelProvider]) + } + + @Test + func `codex telemetry total input matches Claude Code cache split`() throws { + let timestamp = Date(timeIntervalSince1970: 1_784_179_200) + let resolver = CLIProxyAPIAttributionResolver( + observations: [], + usageRecords: [ + CLIProxyAPIUsageRecord( + timestamp: timestamp, + provider: "codex", + executorType: "CodexExecutor", + model: "gpt-5.5", + alias: "gpt-5.5", + endpoint: "POST /v1/messages", + authType: "oauth", + requestID: "request-1", + failed: false, + tokens: .init( + input: 80, + output: 20, + cached: 30, + cacheRead: 30, + cacheCreation: 40, + total: 100)), + ]) + let request = CLIProxyAPIAttributionResolver.Request( + model: "gpt-5.5", + modelProvider: .openAI, + sessionID: "session-1", + timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), + tokens: Self.tokens) + + let attribution = try #require(resolver.attributions(for: [request]).first) + + #expect(attribution.route == .cliProxyAPI) + #expect(attribution.upstream?.provider == "codex") + #expect(attribution.upstream?.authType == .oauth) + } + + @Test + func `duplicate transcript copies share telemetry only proof by occurrence`() { + let timestamp = Date(timeIntervalSince1970: 1_784_179_200) + let resolver = CLIProxyAPIAttributionResolver( + observations: [], + usageRecords: [ + Self.record(timestamp: timestamp, provider: "codex", authType: "oauth"), + ]) + let requests = [0, 1].map { offset in + CLIProxyAPIAttributionResolver.Request( + model: "gpt-5.5", + modelProvider: .openAI, + sessionID: "session-1", + timestampUnixMs: Int64(timestamp.addingTimeInterval(Double(offset)).timeIntervalSince1970 * 1000), + tokens: Self.tokens, + occurrenceID: "response-1") + } + + let attributions = resolver.attributions(for: requests) + + #expect(attributions.map(\.route) == [.cliProxyAPI, .cliProxyAPI]) + #expect(attributions.allSatisfy { $0.upstream?.provider == "codex" }) + } + + @Test + func `telemetry only match stays unclaimed when two transcript requests share it`() { + let timestamp = Date(timeIntervalSince1970: 1_784_179_200) + let resolver = CLIProxyAPIAttributionResolver( + observations: [], + usageRecords: [ + Self.record(timestamp: timestamp, provider: "codex", authType: "oauth"), + ]) + let requests = ["session-1", "session-2"].map { sessionID in + CLIProxyAPIAttributionResolver.Request( + model: "gpt-5.5", + modelProvider: .openAI, + sessionID: sessionID, + timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), + tokens: Self.tokens) + } + + let attributions = resolver.attributions(for: requests) + + #expect(attributions.map(\.route) == [.unknown, .unknown]) + #expect(attributions.allSatisfy { $0.upstream == nil }) + #expect(attributions.allSatisfy { !$0.evidence.contains(.cliProxyUsageTelemetry) }) + } + + @Test + func `telemetry only match rejects a distant record`() throws { + let timestamp = Date(timeIntervalSince1970: 1_784_179_200) + let resolver = CLIProxyAPIAttributionResolver( + observations: [], + usageRecords: [ + Self.record( + timestamp: timestamp.addingTimeInterval(-31), + provider: "codex", + authType: "oauth"), + ]) + let request = CLIProxyAPIAttributionResolver.Request( + model: "gpt-5.5", + modelProvider: .openAI, + sessionID: "session-1", + timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), + tokens: Self.tokens) + + let attribution = try #require(resolver.attributions(for: [request]).first) + + #expect(attribution.route == .unknown) + #expect(attribution.upstream == nil) + } + @Test func `ambiguous telemetry does not claim an upstream`() { let timestamp = Date(timeIntervalSince1970: 1_784_179_200) From b014a8d146a48892ef42de3a925a5f00eb3c325d Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 26 Aug 2026 20:04:35 -0700 Subject: [PATCH 158/183] Isolate proxy collector telemetry test --- Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift index 774564e3bf..6f6c42feaa 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift @@ -694,7 +694,8 @@ struct CLIProxyAPIUsageStoreTests { .failed("replacement endpoint unavailable"), collectorState: CLIProxyAPIUsageCollectorState( configurationGeneration: "before-replacement"), - configurationGeneration: { "after-replacement" }) + configurationGeneration: { "after-replacement" }, + telemetryRevision: { nil }) #expect(collectorState.configurationAvailability == .unavailable) #expect(collectorState.configurationGeneration == "after-replacement") @@ -705,7 +706,8 @@ struct CLIProxyAPIUsageStoreTests { collectorState = await store.handleCLIProxyAPIUsageCollectionResult( .failed("still unavailable"), collectorState: collectorState, - configurationGeneration: { "after-replacement" }) + configurationGeneration: { "after-replacement" }, + telemetryRevision: { nil }) #expect(store.tokenSnapshot(for: .codex) == snapshot) } From 3bc6e5c88e5977e084c8c6a9fac3be53d630884f Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 26 Aug 2026 20:25:42 -0700 Subject: [PATCH 159/183] Preserve durable proxy route evidence --- .../CostUsage/CostUsageScanner+Claude.swift | 22 ++++++++++++++----- .../CLIProxyAPIAliasRegressionTests.swift | 18 +++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 3f94a90def..660c1fc608 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -1428,13 +1428,25 @@ extension CostUsageScanner { cached: CostUsageAttribution?) -> CostUsageAttribution { guard live.route == .cliProxyAPI, - live.upstream == nil, let cached, - cached.route == .cliProxyAPI, - cached.upstream != nil, - cached.evidence.contains(.cliProxyUsageTelemetry) + cached.route == .cliProxyAPI + else { return live } + if live.upstream == nil, + cached.upstream != nil, + cached.evidence.contains(.cliProxyUsageTelemetry) + { + return cached + } + guard live.client == cached.client, + live.modelProvider == cached.modelProvider, + live.upstream == cached.upstream else { return live } - return cached + return CostUsageAttribution( + client: live.client, + route: live.route, + modelProvider: live.modelProvider, + upstream: live.upstream, + evidence: Set(live.evidence).union(cached.evidence).sorted { $0.rawValue < $1.rawValue }) } static func shouldPreserveCachedCLIProxyAPIAttribution( diff --git a/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift b/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift index fba3933c38..17fd04a3fc 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift @@ -112,6 +112,24 @@ struct CLIProxyAPIAliasRegressionTests { #expect(CostUsageScanner.preferredCLIProxyAPIAttribution(live: live, cached: cached) == cached) } + @Test + func `matching live telemetry preserves cached request log evidence`() { + let cached = CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .openAI, + upstream: .init(provider: "codex", authType: .oauth, model: "gpt-5.5"), + evidence: [.cliProxyRequestLog, .cliProxyUsageTelemetry, .modelProvider]) + let live = CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .openAI, + upstream: .init(provider: "codex", authType: .oauth, model: "gpt-5.5"), + evidence: [.cliProxyUsageTelemetry, .modelProvider]) + + #expect(CostUsageScanner.preferredCLIProxyAPIAttribution(live: live, cached: cached) == cached) + } + @Test func `ambiguous live batch match preserves durable cached telemetry`() { let telemetry = CostUsageAttribution( From d7c740a53674bacc06ab95fc0fafd7f649beca39 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 26 Aug 2026 20:30:55 -0700 Subject: [PATCH 160/183] Split proxy attribution reconciliation --- .../Generated/CodexParserHash.generated.swift | 2 +- ...tUsageScanner+CLIProxyAPIAttribution.swift | 27 +++++++++++++++++++ .../CostUsage/CostUsageScanner+Claude.swift | 26 ------------------ 3 files changed, 28 insertions(+), 27 deletions(-) create mode 100644 Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CLIProxyAPIAttribution.swift diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 1ea2951253..779de9081b 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 = "55fbe05f88f0583b" + static let value = "4ef786808187969b" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CLIProxyAPIAttribution.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CLIProxyAPIAttribution.swift new file mode 100644 index 0000000000..765b5c2848 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CLIProxyAPIAttribution.swift @@ -0,0 +1,27 @@ +extension CostUsageScanner { + static func preferredCLIProxyAPIAttribution( + live: CostUsageAttribution, + cached: CostUsageAttribution?) -> CostUsageAttribution + { + guard live.route == .cliProxyAPI, + let cached, + cached.route == .cliProxyAPI + else { return live } + if live.upstream == nil, + cached.upstream != nil, + cached.evidence.contains(.cliProxyUsageTelemetry) + { + return cached + } + guard live.client == cached.client, + live.modelProvider == cached.modelProvider, + live.upstream == cached.upstream + else { return live } + return CostUsageAttribution( + client: live.client, + route: live.route, + modelProvider: live.modelProvider, + upstream: live.upstream, + evidence: Set(live.evidence).union(cached.evidence).sorted { $0.rawValue < $1.rawValue }) + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 660c1fc608..fd109b1539 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -1423,32 +1423,6 @@ extension CostUsageScanner { return result } - static func preferredCLIProxyAPIAttribution( - live: CostUsageAttribution, - cached: CostUsageAttribution?) -> CostUsageAttribution - { - guard live.route == .cliProxyAPI, - let cached, - cached.route == .cliProxyAPI - else { return live } - if live.upstream == nil, - cached.upstream != nil, - cached.evidence.contains(.cliProxyUsageTelemetry) - { - return cached - } - guard live.client == cached.client, - live.modelProvider == cached.modelProvider, - live.upstream == cached.upstream - else { return live } - return CostUsageAttribution( - client: live.client, - route: live.route, - modelProvider: live.modelProvider, - upstream: live.upstream, - evidence: Set(live.evidence).union(cached.evidence).sorted { $0.rawValue < $1.rawValue }) - } - static func shouldPreserveCachedCLIProxyAPIAttribution( _ cached: CostUsageAttribution?, allowCached: Bool, From de74b3139a31d309b578c90df3556ed80a8307c7 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 26 Aug 2026 20:54:32 -0700 Subject: [PATCH 161/183] Align changelog before main merge --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61d0423d76..c6f58cf153 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed - CLIProxyAPI attribution: identify Claude Code requests from uniquely matching management-queue telemetry even when CLIProxyAPI request-file logging is disabled, so routed Codex models display their upstream and `CLIProxyAPI via Claude Code` provenance. +- Codex: clear stale connectivity errors after an authorized successful fetch even when weekly usage is withheld, including persisted and stacked-account errors (#3214). Thanks @olddonkey! - Antigravity: select the most constrained known quota independently for session and weekly menu-bar layout tokens, so unused model families no longer mask consumed quota (#3206). Thanks @foobra! - OpenCode Go: preserve API percentage units so 1% usage no longer appears as 100%, including local-history overlays (#3216). Thanks @rodrigoalma! - CLI install: block inherited shell functions and startup hooks before helper validation and failure handling, and use absolute tools and a clean administrator-command environment while retaining approval and both existing symlink destinations (#3205, #3217). From 5798cdaa4ed65ba91a2dfca0649cf35e6937bd37 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 26 Aug 2026 20:55:32 -0700 Subject: [PATCH 162/183] Align changelog with main for update --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6f58cf153..f290162080 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,6 @@ ## 0.55.2 — Unreleased ### Fixed -- CLIProxyAPI attribution: identify Claude Code requests from uniquely matching management-queue telemetry even when CLIProxyAPI request-file logging is disabled, so routed Codex models display their upstream and `CLIProxyAPI via Claude Code` provenance. - Codex: clear stale connectivity errors after an authorized successful fetch even when weekly usage is withheld, including persisted and stacked-account errors (#3214). Thanks @olddonkey! - Antigravity: select the most constrained known quota independently for session and weekly menu-bar layout tokens, so unused model families no longer mask consumed quota (#3206). Thanks @foobra! - OpenCode Go: preserve API percentage units so 1% usage no longer appears as 100%, including local-history overlays (#3216). Thanks @rodrigoalma! From 01aa885575e6d67ca10bb383abd725245b83e947 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 26 Aug 2026 21:02:32 -0700 Subject: [PATCH 163/183] Refresh changelog alignment with main --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f290162080..bb3b8ea930 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.55.2 — Unreleased ### Fixed +- Claude: respect the used/remaining fill preference for capped Extra Usage, so an exhausted cap is empty in remaining mode (#3213). Thanks @vinschger! - Codex: clear stale connectivity errors after an authorized successful fetch even when weekly usage is withheld, including persisted and stacked-account errors (#3214). Thanks @olddonkey! - Antigravity: select the most constrained known quota independently for session and weekly menu-bar layout tokens, so unused model families no longer mask consumed quota (#3206). Thanks @foobra! - OpenCode Go: preserve API percentage units so 1% usage no longer appears as 100%, including local-history overlays (#3216). Thanks @rodrigoalma! From cb7e1b16ceba14b5af96f54051e5b23fb337a2c7 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 26 Aug 2026 20:17:51 -0700 Subject: [PATCH 164/183] Isolate browser uninstall test --- Tests/CodexBarTests/BrowserDetectionTests.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Tests/CodexBarTests/BrowserDetectionTests.swift b/Tests/CodexBarTests/BrowserDetectionTests.swift index c1501e4de3..695d4a4fad 100644 --- a/Tests/CodexBarTests/BrowserDetectionTests.swift +++ b/Tests/CodexBarTests/BrowserDetectionTests.swift @@ -585,6 +585,7 @@ struct BrowserDetectionTests { let detection = BrowserDetection( homeDirectory: temp.path, cacheTTL: 600, + now: Date.init, fileExists: { path in if path == "/Applications/Google Chrome.app" { return installed.withLock { $0 } @@ -593,7 +594,9 @@ struct BrowserDetectionTests { }, directoryContents: { path in try? FileManager.default.contentsOfDirectory(atPath: path) - }) + }, + applicationURLs: { _ in [] }, + profileAccessIssue: { _ in nil }) #expect(detection.isCookieSourceAvailable(.chrome)) installed.withLock { $0 = false } From a579a12f61b77e96f2d85802ce2951c72b0a925b Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 26 Aug 2026 20:25:42 -0700 Subject: [PATCH 165/183] Preserve durable proxy route evidence --- .../CostUsage/CostUsageScanner+Claude.swift | 22 ++++++++++++++----- .../CLIProxyAPIAliasRegressionTests.swift | 18 +++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 3f94a90def..660c1fc608 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -1428,13 +1428,25 @@ extension CostUsageScanner { cached: CostUsageAttribution?) -> CostUsageAttribution { guard live.route == .cliProxyAPI, - live.upstream == nil, let cached, - cached.route == .cliProxyAPI, - cached.upstream != nil, - cached.evidence.contains(.cliProxyUsageTelemetry) + cached.route == .cliProxyAPI + else { return live } + if live.upstream == nil, + cached.upstream != nil, + cached.evidence.contains(.cliProxyUsageTelemetry) + { + return cached + } + guard live.client == cached.client, + live.modelProvider == cached.modelProvider, + live.upstream == cached.upstream else { return live } - return cached + return CostUsageAttribution( + client: live.client, + route: live.route, + modelProvider: live.modelProvider, + upstream: live.upstream, + evidence: Set(live.evidence).union(cached.evidence).sorted { $0.rawValue < $1.rawValue }) } static func shouldPreserveCachedCLIProxyAPIAttribution( diff --git a/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift b/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift index fba3933c38..17fd04a3fc 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift @@ -112,6 +112,24 @@ struct CLIProxyAPIAliasRegressionTests { #expect(CostUsageScanner.preferredCLIProxyAPIAttribution(live: live, cached: cached) == cached) } + @Test + func `matching live telemetry preserves cached request log evidence`() { + let cached = CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .openAI, + upstream: .init(provider: "codex", authType: .oauth, model: "gpt-5.5"), + evidence: [.cliProxyRequestLog, .cliProxyUsageTelemetry, .modelProvider]) + let live = CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .openAI, + upstream: .init(provider: "codex", authType: .oauth, model: "gpt-5.5"), + evidence: [.cliProxyUsageTelemetry, .modelProvider]) + + #expect(CostUsageScanner.preferredCLIProxyAPIAttribution(live: live, cached: cached) == cached) + } + @Test func `ambiguous live batch match preserves durable cached telemetry`() { let telemetry = CostUsageAttribution( From bc560464db281e09e1cbad8dac3852f7089785f9 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 26 Aug 2026 20:30:55 -0700 Subject: [PATCH 166/183] Split proxy attribution reconciliation --- .../Generated/CodexParserHash.generated.swift | 2 +- ...tUsageScanner+CLIProxyAPIAttribution.swift | 27 +++++++++++++++++++ .../CostUsage/CostUsageScanner+Claude.swift | 26 ------------------ 3 files changed, 28 insertions(+), 27 deletions(-) create mode 100644 Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CLIProxyAPIAttribution.swift diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 1ea2951253..779de9081b 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 = "55fbe05f88f0583b" + static let value = "4ef786808187969b" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CLIProxyAPIAttribution.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CLIProxyAPIAttribution.swift new file mode 100644 index 0000000000..765b5c2848 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CLIProxyAPIAttribution.swift @@ -0,0 +1,27 @@ +extension CostUsageScanner { + static func preferredCLIProxyAPIAttribution( + live: CostUsageAttribution, + cached: CostUsageAttribution?) -> CostUsageAttribution + { + guard live.route == .cliProxyAPI, + let cached, + cached.route == .cliProxyAPI + else { return live } + if live.upstream == nil, + cached.upstream != nil, + cached.evidence.contains(.cliProxyUsageTelemetry) + { + return cached + } + guard live.client == cached.client, + live.modelProvider == cached.modelProvider, + live.upstream == cached.upstream + else { return live } + return CostUsageAttribution( + client: live.client, + route: live.route, + modelProvider: live.modelProvider, + upstream: live.upstream, + evidence: Set(live.evidence).union(cached.evidence).sorted { $0.rawValue < $1.rawValue }) + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 660c1fc608..fd109b1539 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -1423,32 +1423,6 @@ extension CostUsageScanner { return result } - static func preferredCLIProxyAPIAttribution( - live: CostUsageAttribution, - cached: CostUsageAttribution?) -> CostUsageAttribution - { - guard live.route == .cliProxyAPI, - let cached, - cached.route == .cliProxyAPI - else { return live } - if live.upstream == nil, - cached.upstream != nil, - cached.evidence.contains(.cliProxyUsageTelemetry) - { - return cached - } - guard live.client == cached.client, - live.modelProvider == cached.modelProvider, - live.upstream == cached.upstream - else { return live } - return CostUsageAttribution( - client: live.client, - route: live.route, - modelProvider: live.modelProvider, - upstream: live.upstream, - evidence: Set(live.evidence).union(cached.evidence).sorted { $0.rawValue < $1.rawValue }) - } - static func shouldPreserveCachedCLIProxyAPIAttribution( _ cached: CostUsageAttribution?, allowCached: Bool, From 913d85049930493e966391070b74fe924caddba0 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Wed, 26 Aug 2026 21:03:50 -0700 Subject: [PATCH 167/183] Restore proxy attribution changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb3b8ea930..9446238266 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.55.2 — Unreleased ### Fixed +- CLIProxyAPI attribution: identify Claude Code requests from uniquely matching management-queue telemetry even when CLIProxyAPI request-file logging is disabled, so routed Codex models display their upstream and `CLIProxyAPI via Claude Code` provenance. - Claude: respect the used/remaining fill preference for capped Extra Usage, so an exhausted cap is empty in remaining mode (#3213). Thanks @vinschger! - Codex: clear stale connectivity errors after an authorized successful fetch even when weekly usage is withheld, including persisted and stacked-account errors (#3214). Thanks @olddonkey! - Antigravity: select the most constrained known quota independently for session and weekly menu-bar layout tokens, so unused model families no longer mask consumed quota (#3206). Thanks @foobra! From cb8af2859a7f2bc4892bde801fb0b6a53f346190 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Thu, 27 Aug 2026 10:23:37 -0700 Subject: [PATCH 168/183] Add CLIProxyAPI routed proof screenshot --- .../cliproxyapi-attribution-proof.png | Bin 0 -> 76739 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/screenshots/cliproxyapi-attribution-proof.png diff --git a/docs/screenshots/cliproxyapi-attribution-proof.png b/docs/screenshots/cliproxyapi-attribution-proof.png new file mode 100644 index 0000000000000000000000000000000000000000..ea5b6f75d31d33837fb0658aaf73680d192a49e9 GIT binary patch literal 76739 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
  • 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 Vbblqd>;wKxFPmQ~xM=_Q{{X?u>@WZT literal 0 HcmV?d00001 From ff946d2c34fac00b742625bbcd2929ff7ed05259 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Thu, 27 Aug 2026 14:25:48 -0700 Subject: [PATCH 169/183] Add redirect final-effect proof --- Sources/CodexBarCLI/CLILocalHTTPServer.swift | 3 + .../CLIProxyAPIUsageCollectorTests.swift | 151 ++++++++++++++++-- ...liproxyapi-redirect-final-effect-proof.png | Bin 0 -> 173639 bytes 3 files changed, 142 insertions(+), 12 deletions(-) create mode 100644 docs/screenshots/cliproxyapi-redirect-final-effect-proof.png diff --git a/Sources/CodexBarCLI/CLILocalHTTPServer.swift b/Sources/CodexBarCLI/CLILocalHTTPServer.swift index 9be204eeee..8c93443b61 100644 --- a/Sources/CodexBarCLI/CLILocalHTTPServer.swift +++ b/Sources/CodexBarCLI/CLILocalHTTPServer.swift @@ -173,6 +173,7 @@ enum CLILocalHTTPRequestParseError: Error, Equatable { enum CLIHTTPStatus { case ok + case temporaryRedirect case badRequest case unauthorized case forbidden @@ -183,6 +184,7 @@ enum CLIHTTPStatus { var code: Int { switch self { case .ok: 200 + case .temporaryRedirect: 307 case .badRequest: 400 case .unauthorized: 401 case .forbidden: 403 @@ -196,6 +198,7 @@ enum CLIHTTPStatus { var reason: String { switch self { case .ok: "OK" + case .temporaryRedirect: "Temporary Redirect" case .badRequest: "Bad Request" case .unauthorized: "Unauthorized" case .forbidden: "Forbidden" diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift index d9614492fe..1f6c583f02 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +@testable import CodexBarCLI @testable import CodexBarCore private actor CLIProxyAPICollectionContinuationProbe { @@ -16,21 +17,147 @@ private actor CLIProxyAPICollectionContinuationProbe { } } -struct CLIProxyAPIUsageCollectorTests { - @Test - func `queue client live session rejects hostile redirects carrying the management key`() throws { - let session = CLIProxyAPIUsageQueueClient.liveURLSession() - defer { session.invalidateAndCancel() } - #expect(session.delegate is ProviderHTTPRedirectGuardDelegate) +private final class CLIProxyAPIRedirectProofRecorder: @unchecked Sendable { + struct Snapshot: Sendable { + let originRequests: Int + let originAuthorizations: Int + let targetRequests: Int + let targetAuthorizations: Int + } - var redirectRequest = try URLRequest(url: #require(URL(string: "http://127.0.0.1:9999/capture"))) - redirectRequest.setValue("Bearer management-secret", forHTTPHeaderField: "Authorization") + private let lock = NSLock() + private var originRequests = 0 + private var originAuthorizations = 0 + private var targetRequests = 0 + private var targetAuthorizations = 0 + + func recordOrigin(authorization: String?) { + self.lock.withLock { + self.originRequests += 1 + if authorization != nil { + self.originAuthorizations += 1 + } + } + } + + func recordTarget(authorization: String?) { + self.lock.withLock { + self.targetRequests += 1 + if authorization != nil { + self.targetAuthorizations += 1 + } + } + } + + func snapshot() -> Snapshot { + self.lock.withLock { + Snapshot( + originRequests: self.originRequests, + originAuthorizations: self.originAuthorizations, + targetRequests: self.targetRequests, + targetAuthorizations: self.targetAuthorizations) + } + } +} + +private final class CLIProxyAPIRedirectListeningSignal: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var isSignaled = false + + func signal() { + let continuation = self.lock.withLock { + self.isSignaled = true + defer { self.continuation = nil } + return self.continuation + } + continuation?.resume() + } - let guarded = ProviderHTTPRedirectGuardDelegate.guardedRedirectRequest( - originalURL: URL(string: "http://127.0.0.1:8317/v0/management/usage-queue"), - redirectRequest: redirectRequest) + func wait() async { + await withCheckedContinuation { continuation in + let shouldResume = self.lock.withLock { + guard !self.isSignaled else { return true } + self.continuation = continuation + return false + } + if shouldResume { + continuation.resume() + } + } + } +} - #expect(guarded == nil) +struct CLIProxyAPIUsageCollectorTests { + @Test(.serialized) + func `queue client blocks hostile redirects before redirected IO`() async throws { + let recorder = CLIProxyAPIRedirectProofRecorder() + let targetListening = CLIProxyAPIRedirectListeningSignal() + let targetServer = CLILocalHTTPServer(host: "127.0.0.1", port: 0) { request in + recorder.recordTarget(authorization: request.authorization) + return CLILocalHTTPResponse(status: .ok, body: Data("[]".utf8)) + } + let targetTask = Task { + try await targetServer.run { + targetListening.signal() + } + } + await targetListening.wait() + let targetPort = try #require(targetServer.listeningPort) + + let originListening = CLIProxyAPIRedirectListeningSignal() + let originServer = CLILocalHTTPServer(host: "127.0.0.1", port: 0) { request in + recorder.recordOrigin(authorization: request.authorization) + return CLILocalHTTPResponse( + status: .temporaryRedirect, + body: Data(), + extraHeaders: [("Location", "http://127.0.0.1:\(targetPort)/capture")]) + } + let originTask = Task { + try await originServer.run { + originListening.signal() + } + } + await originListening.wait() + defer { + originServer.stop() + targetServer.stop() + } + let originPort = try #require(originServer.listeningPort) + let client = CLIProxyAPIUsageQueueClient(settings: .init( + baseURL: "http://127.0.0.1:\(originPort)", + managementKey: "redacted-proof-token")) + + do { + _ = try await client.pop(count: 1) + Issue.record("The hostile redirect unexpectedly returned a queue response.") + } catch { + // A 307 response is the expected final response when the redirect delegate refuses to follow it. + } + try await Task.sleep(for: .milliseconds(250)) + + let snapshot = recorder.snapshot() + print(""" + + CLIProxyAPI redirect final-effect proof + original loopback request received: \(snapshot.originRequests == 1 ? "yes" : "no") + original bearer header present: \(snapshot.originAuthorizations == 1 ? "yes (redacted)" : "no") + hostile redirect response issued: \(snapshot.originRequests == 1 ? "yes" : "no") + redirected endpoint requests: \(snapshot.targetRequests) + redirected bearer headers received: \(snapshot.targetAuthorizations) + result: \(snapshot.targetRequests == 0 ? "PASS - blocked before redirected I/O" : "FAIL") + + """) + + #expect(snapshot.originRequests == 1) + #expect(snapshot.originAuthorizations == 1) + #expect(snapshot.targetRequests == 0) + #expect(snapshot.targetAuthorizations == 0) + + originServer.stop() + targetServer.stop() + try await originTask.value + try await targetTask.value } @Test diff --git a/docs/screenshots/cliproxyapi-redirect-final-effect-proof.png b/docs/screenshots/cliproxyapi-redirect-final-effect-proof.png new file mode 100644 index 0000000000000000000000000000000000000000..5d10f7fa4987c6426cc9c3aae89c6d5150118125 GIT binary patch literal 173639 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 z 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 literal 0 HcmV?d00001 From edeacec0638227c5d96c87bf8a7cb82ccd9c8a30 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Thu, 27 Aug 2026 15:22:10 -0700 Subject: [PATCH 170/183] Remove proof screenshots --- .../cliproxyapi-attribution-proof.png | Bin 76739 -> 0 bytes ...cliproxyapi-redirect-final-effect-proof.png | Bin 173639 -> 0 bytes 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 docs/screenshots/cliproxyapi-attribution-proof.png delete mode 100644 docs/screenshots/cliproxyapi-redirect-final-effect-proof.png diff --git a/docs/screenshots/cliproxyapi-attribution-proof.png b/docs/screenshots/cliproxyapi-attribution-proof.png deleted file mode 100644 index ea5b6f75d31d33837fb0658aaf73680d192a49e9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 76739 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

  • *H8E{+v3yCVNAii$n|aTF-!dR*4S>gzpo!&(RoDgU;^r()$yDWi zuDr2PJRnhP*N^5ZA?LNYVfpFI*(<4(XR>EnEgtZ1KX~z?Rhy(~iO6r0a~>2pv$DsP z?c2}P&yPiyHb{mVj_l8sI4>pXH#~1y?7LjUtG0w*FBeL+Tw5xe&u(qDT+5%vD*>=t z$sDvD62L#p-dGy_uqtsR?e}@#4jR@|-#Wlp4CWZRE-wb>Z1W}ASxV@f{g$2;xnbu3 zifIQHq*3W=b0fr8k8#oV_tnSGt!d;lSd?;Yc?B$eT8ENYC3`hzw4uHe!!3*&+OhmK z0}(JG5_&F>%dMD@JIFu%MmI*h8xgPkp50}cc6)WWz6lZhiNyg(2P{P(IwG;TV^TX- z(0_fbPTa*jWQ(ts@|5Hj>~{Wx3Ze3XRhXbVtZ5|-Jy!vtuBvpjee!f=G83$|@XL}m z-@@g0d{^5z0VjxW%6V0imKJODg|t7BPf=cVX=Ql!aZV?COnsErzKM^j7_n?l%unbV zrT?@5Ty$yvwA!l}pr_UeP2cF5{Im~;?E)9RNvD*n5S~}Zrfx7>ia%)pg-5U(F)i|7#P)V{);ueA@ zar<{r8)UJLivUY#!9x|y8hTxMVI5as&|nA`vq9B$Q{&2tsUA6Aczhf0qG0B+{N8%Cgq(8Ut#|NZOm za8yYlz@`X}spN1w1yP-q4*ev6m1rlyr2{=ZYX4Ak99e#(<~U^GYN->f|C=<0$vMDt zY;taSUpzOmyXr<+VY)O&B^>_t+Wr-lYTge}2#s$=S7+kQoJ-SYxh)qS zI*HG-ys98J_x`G6_}fRC#bq1ktOi?jB01bnCgYX&JD0S<^{*P$2seMhSf|I_xea3! zOOHMCr^Mna?vmLxx9lSkRkak)-_mTd_IDUHWu+HRS1j(Ng}=?&Eg3&U3+N%{YDo@C zf`o=y{jD490Zs*UtF7TbKwtdrIJ7CGv>c_1~OL! z09>I%bJfVKkOm3N#g zD=`DGm8y4sB7bY8aA(~W&Htn4QQ(5vb3#Imaqkf39D2b4>HjiZ(s zmhIb>lOE3HF&=~zZ%dj|Q)Uz93Q*|}Gj~0%`|I?Z?*`~a$&o!dmBPql6roOTNNZa9 zrSFn%)WzR4JbL6hfQ1q}bMo2hk97}>)8Wnj&R?gc&(ZJ88%r)f_;(MPbEQ8vpZP7~ z2S}ux3jFsgKiDPUpdSZ4RsGe$E(}aE;}_t}or_?A^YiR-0{(>q^Iz|`8N4)%33za^ zjei<^z%F(>4<@9BXO~hfmUWxims5ic%0GOnf9v@+jl=251z-?+y}Qfhu2YK2_{ejp z$4PwlK)1hlj-|tD@kVlG*}8_^xis)UKV`Z^i9^9VfBor`>)`o(@M-EZUH)HxJcBzl zE`sEL{mC^<6L@f8m-gY`FF*vPd;D?>?!ByHb;bCS4*6aF{Q@04rwG5_g>w_x4XK?P549NeufQ5{w;IESbe0JPP1lMe^3OO~_ zqS8L(`e)oPJ1BVoY#Dg395XS3Lk-xAN2;``TOZm0JZPWD#dy}jm)%QwkGOI?wTP;7 z0iIeMCiUpCGsgJjEChx%kF2KyDklkX3n(+%JCn+LwQe;$V&Sg=k6_={;k@E+@atVT zjaSmlLBk9<-WbMO*`KPzrnh6HdW|*;h8uQ<=RgagKotXSMGgHBhJ5)g=Sm9tl@9f! z-*-Cxn{*plma%pei|fRb;7v&hKSwPg1yYLW62u}QUqx!V0sWYAx3zvdmm$yY3$kyx zzLiq)kVm`I2Z0jfr`7_pA@vT~rFTdjNcTp0+2f**&03gbEJh;ifyP*Ojz^n@oq}I> z+1$J6!C|rUTveaUlr_jey~1_{L7mmNh(FN4Sx!((^_h5b%X-Z);egWJfK~@fm5|#y z{5O|?!>w~>HVbE2Ucx8pr&zK4xn{EIXpV$m-!>sVVnMr3`CG_jcukK9vXcJt{xJ9C z`Q%S&gdh^(%p{MhJIrL*>bEPIc&^)W8H<>s7v76bLoz&iExaR8KG7=3S%1Jo2G^Kr zCVzAJAPM|>*mWK_3#Aw>-L59cWqfED&ZQRE=d<;3VSq|3*<1ix%+B6-&>?S&-p`d!l9*B(SpPCI0M|&-E z50>m|aexRTT&oJjlyV}Fo)a@A#Eh`5c-LKg-8f0>eAGra7D)JUF`wrhVRm5^+P;&s z>h+|s#1kk+5<9P12T4wxE;OlA#N_$UJhwXw@9huNc=*w1CCh0Oq5d>s%`dG(ghu*jT6ml6+i9# z*s`#CmjHNkKUbZ9hgKy}!Lqkz5b*UDlWCJ!mj<3F-sj-!fcmT%+||GPlXXiL0M2Z| zPN<0mGzW0R@2knEHa^bG!v;Dk90XVKpX0Fyn$AGa3bP|z0<1|Zu;uD4nP@fERPP`l zLwAd?nuB5Sh4k-!iJ4Ks?F|!RFV7bZI*X$%{bpZTyDdj!18l$%s4BXA!*s9z2sL#_ zIC}aha__?R1!8PrcQ%Hbw{v!k4*2^2(5}0z`!wNeJJ&}W4M`LfvTjy1^>V>%4Dt$5 zP~#>%1Le2$-`b5Zd!#-?~n?DclU~Mrr(~bR_bV#|2BMj`f>i<-^@MD;GHZX+|jy8q)3^vsTE;ya*(z z>*Qdeu6pMe-nEsl(Ha?$73Jl2 z5PVd1&Et4*j10ja@GAEOubKwdM7HO4H&&THx0>|=Xi>>`>GH449iKYCB7?;?P|>ry z8^)^NPmqs%og;~ zh6p-pQw!?U-C4G`ms)|&X;$8Yc@Pn11<(ykO>Eyl=PN|tImg={kqj!3<7BgZ5xz!g zR+2_VNTdD9!`lPbY(gy%cclVXI`b}Cdo=Dcvc9cFV*zaf9l_0QlD-+y)#5oXb8AN> zs459bH2LGZ)cGdBiXjSm z541Lmm@d2o-x}c41S9MrdphgVb(|XPvr>hodgY6F;G$9?d-O7_o+{QnV`y|X$IGH~ zqpMe8|N*BdFZmNnADo=$O}Oc~i0qBn2weV@qGgkNU5OPZ07DK{r4OFFZfPc{1( z^qZz`><(L1s zLxnt}TP3X8HTF*L#I-{6E&T-vHp{Nrsg*713+jJ-{bvd1Sb^Z~!7_XDYL60K6%EMG zeP{Kh86ACkyVd#j?5vhc6T888)m@HHB=_a{C>FPaA@~wVzMO@j)k>eOY;WjZ5Z-jJ zK#`v|VG`m*85r>-H3nP3(~>Rj)I?Pq;YnlEo}4}WKA%MRqStN4F`RV_AciVl)eSMo zFsfYQ`*6;4Y@&b@avYaZe-X!1yF%Uwg)zs8IFF)*F>145p5z>ck%if}Iz?~fUC`b? zF00ynI{t#6mVJj3v_HlpoV15JYDYizh8WAd8EvCQcL;p}s5!-+xZO*Caxj48W9Qhv zoWdWN9P4NO28Q~4V&(mLvL42j>RBVmRwjP83;Z$%$Peyu7uC(4O;0c$B3{mJEqmrx zD^IOg&&T3Q+1xXLc3!$|CJS9Ld%p~5)Son`?YCQurU+J2+2w3m0S zqHY8z2ol6-KfmmvGk@Q=7KZ=)MK!+Uoj2>gG}EfZX!4$v|7TmB;k5~GE8;8MhdeIk z(K+s;O&JwY)TG|LS^sAG=UUyxC(+1r3Z$|*ton@ZL4!+*KG6co;M4uruBu8umpGhR zzn|Y{R@9Z|^F;Sd)S37c1?7gLW(hkty-ux2YtU0e&Kh6T&(SZfEe(2{3Vt*&m&4{S zxx8fV8`)65mi%5sf?^SL5NpAD?XhW>qA8jw|%y*%(4zqgeqElRTx-|x6--mO2-c4rO zNJz{{ct(#Nd0WhPLM^#Fj($c>nrPDua!c(Um@W1zEuzRdMTGi!1#~?U3|!u!JuX{>HK5=yEoK zP(hu+MWz~_-bw^I3O<%vm@O_aZc}Hm*YVQmfotD$t_5eHn7s&AEE{Ml=1+R&Q2ee{ zc%Vi)(56?g(gQcHw$*WO7PseLEfPOslT11N;GXpt%$hbJ+sX~pkNI_nArhIR0xh3L z@138e-dqNs!lZOJzjpIKUe2B zeu+|^km}F-Y3_Ww5+IXO&C*TB^g5jn8m>UZrqsbwP2l6%JbITYA06*PT=O>>j2VP( zGL4e0ej?@DMpr8F;ESOFN9Up09FMX_{_XMvGkMD8mClbXC(GM-Y?+=B@+qX*EgIYV zCb462Fwm*S(xBt0v*Qy&l>=R}L0^MY`*pONGD4-9`ND25Bc&O-qt^ow{(({EMx8K4 z6dl5{g6a^ldDBMBakYa+iUZ;pUq(BOGE?M%$fZ5qKs`zA&z_eUm^*4AsG~{C*6MY1 zP=T!0$&y-nTW~9vD(`<-%hSg?pd@zgH7wO|lxpx;ytyH#+V>LXb>m3=F^~Oz-Pyi> zdkZA4f#A-;G6uqTH(H8Q&IUiQnq~o>ro=6#hiI71sWN@(xGSKi&q_si$#;Zo1o2_=tR8OpXylaZ^?(##AORXEaa0t#sE+zji$In?t)4PBzO)-601tEB(CT zee7+*!}oa3HDeQ6ziICX++|rr;tr{?&3ezVCfZqJ;lmmxsEotmQ|}#JUf}|m)uEi3 z$GcvO?jz$OhF|6yLH#CV9|?L1dVG_3`mrI8kc^3Os~wsTTIw7h()%6_**Wl1X+ZNp zrn8QBrA0;pUNmke&Vu0euKo9o_W43RDxZfKvZK71y~PP7&vSqt{=@OS!?k!KT&ZLqL zd9O!UB3Rv#?budZ?M+Gu1v#z0Y22A`kA>veN$gtcNN%%Q*dKwrKI|1)TUfl;j4Vw-vuSY$SgswA ztownQ7O}-5OC1nBDg@BOjgFlTTRLQQM}k)K8dc}4aWH$eKBSJ58KsOe>c3Q43k%3A z$l>VRM-Y1P?A2Rv*zp=8OdS>>)X7r^9C*xl@x-X(l?#12{GH9xZ2GE<^32gnOQk(Y#dZB6a(w*^CiF&>n1iqlmo8Jq``6DG+a9^FB%wEs@63Z-fwR{eRiVR z@^xRAbQMtuqq0cS?803u>X~$6t{6pTl6#~{s94&ASQL{F;zu@dQ8X;E)1ql({TXK? zIeosBwvG{{CvJu2tSp;l7~zQ>5U&`A>GxfZE;_yOANy?EOY@17Nodu7d_|6q+@qlV zj!}R5EZ0NPxQ}>Ek($EmGnE(bjW@p;kKtrmK>waU=>iNV6}n7lY0- z%t20fju-$4|3TWFw^`C(b5^FH+fl^+KezfEe0&FI@dQgh z31RdP&VTtH(2;}VMmUIB>n~|KnJ<@*RS#HM@xZ=?f?PPIjMy|&xc6#9*%X$x&XReb0I{QU<6o5O~EIT;IB3!Us`mg z%v07ts(J^Knol28l~od!RpO`Ye3O?V6LubSYr(ubR=>aOWu#QT4^haXiH961k94Hv z7&jRgt1{k5c(^k?UoHiN!8&WJ=2vd{FSns>R3f8EOI zmhNq9{exA%LyXmV78fo%nlg`=iKHaM@jOu!Vzc=*1V;T~b6H!o-4$g_?$rA&-o1Ny zbqpNy#xKHoeOd7hvko&7thr-ls}aU<(!O4~>6O>8&ZE7mT8;Bb%6_z{``dNgB_syw z?aEZn<_-Sz07V`N?v7k|lY<2#gRhdh&hcB#j2#%>-E!JEcYjnAcsB|$ZTJ?eQ?uha zAV$I-ov9B3t5@wZTDnkQwdEG?6)QP)I+Fl{g=74Amg0K52?~*!5;NuF9U{^*h7VwyLA_R}Fnns<* zviTpZVpC}!D`h$hQ6ZoEVsp+-b?Ho&o?G;3?sCzP%7d897yxqsfi-Nqxe5uOrRh+w zVC?Ev3_LJIYyb%5;cjuj#k@8Gmp5sCPP?j$q_kJ3JJt=AZ4Q%Lh})|_J>$}3ZBnT} z;q6nRs+4hMBfPjM(zRj!096;NYAwcFb;oV2;_cr)vPjKf*>&*#;D+m4l*SqaS1&v7 zy>To@7N`3#J6YgvvJo@a;Vs%?pb6{c^vnUS=Fk5vK6rM>(t?p><&ll_N<|mEiog~a zFydr9oy980an;JopcV~&+RG_>T(T4^OxnHbv`$%uTCjZ+;!azRLO?vKMW*tnFoyZss48+3_bG@hRkUz;!g9 zc`j%-i+|a@QcOdfacFiB-FJgv$M9Bm%BI>XHFe{Ney>)Jvg|GY@paJvWDDVgSQ~)7 zP|NaRo62i+E=RkUzeHJ0_dg$2!W*9rYOE`h90po)ucU^vzkY9QK-#l3_O{&1{ONzR za^YX$|2#q&`ZlA|hPqE^w|c*8lV*N*)hH0-3jH6EE2vBdgby0zsda#!KS?R*uFrh$ z^jjck%x1?8R)Hd<*>B;0#Em450AVYMcjIqV{ywYzim18f0x>G}8|=b=9_q*ML!Sci ztk8SvbLW4Ha!Duv!7IUd%*TJ+=-j(hryl@9cj@~gHGg`${<;OP5fG=lKaHIK{f=+= zJcC`|Jj!hQZG_b|KwxX!ir(w@`yKzkKWv}XninwocNca7Ch$q88Q?OrbRQ1;$9>NI zpxy*#fX5GE(Hp-{vjFf;6oOXre=dd}F|0^sV7u+e%+&15N*kddpL!b% zZm>)Boq3eTo_n7oMb8)DU*x9BMi6iq^dyi;+I-mtQ0A&)Qkp~Cj%JtKu01gRHY;^s z%{n(l+;$`G-1^&&jIoI@r4jY{4+W~uCF74G0KkzoMi zK6mZW*Q09?l2x>JS3nX+Bj!bUMft_dU2E$0+9l_?XN@jun$5>()~H&nsKbCt66`Zj+Q)(%~&$Rj?-`FxnSYc~2P%r|!EuNwr0-RJh6mp#sHm{3Aq z=|;E=0YMDwEHq?s&=3!V8_r(jaGTmI?Bd4%6SU>i63_^t>)Lv`)jorRBdVgCxoPe< z_Gr<@?$`1_SGywAM$}Ue1^`O>eLmcvL%$v?FD)-9?{4YL3x~RlL((TT58>_=|NGJe zp}@9SvwDzUpxAClB@BBI`swQ@#N0!iN}mmt`qcv=oO$;KQyFug@rTv~4!K@h8kYsz zAQjZZ{Q_n%MJM-tq}3{w&ua9IUAdfH+-18y9J>XMn&?&%*v1kLg3joCt-J0!(6@kf zrZC-ZI8G%mE_dTJrlDKuieOWQvSNW_`@a*D2DUT?HeP~C|FtJ$S8~~H-c=spHWLf4 z9=@|YZ$g}d3(|Tb@oC7?v^8#5JsL5LL6iadLae)J(Whd?0dMm*05}(W?j`rCm&)gEAJV=r63cB5)IJ_{vhC0dfXy_1D6{Mg0LlKo7>G0RrUO z15v%kc<`jC$}AP7^J-*bEXGK2FJlxaRSvhnoTt|ywW>CTmjD;o^jI^npx4MYV*?I3 zk`;{;0fSD(f8f!;rG)UeGk;p}AGW3qupYT8#tyH*!=UR>rcP6gF%5B2A}iB{kpUGfH!_>LY>-M zwN@zmxYdq=S;>@+P@3KLlBbv(|9Lb+dJO3+OcMrg^hlRapqhBfQ%{=%F&=Rt?Ujx` z4Lp~Z->xy%NC8%T`ymeCQ&&NKBVhSI&WBW+jhCw^nq8TK4(wE7r1-x8Rs!3Tc&VscE)fGPS@jj_G*CC*Pvd$EU-);ON`OE_ zc}PS7->NB-Cki`h6VRQaJFCQlkJkOMc@o=P!)xSI>ecfy+JBUy@n`K1-rc7^IBd?7Cy@32n$eM|wjj)?Yr>NqGB+ zp>f9eIXYKNa^=R5f#@0LIoxa*Q`m>xPQv-Qd)|f2DzTU*(wO7hTr@u?!ERl^*51}1 z{E1M1uXL0 zVNNYuSV%T$76$<{1IL1Sx7H3?GTUlmijmjyeF}o^{<=u|G=NotYPA$r(8=$`I-g4tsVCi0>ayK2=IBpKr}`Y!6N6|)8yqD zE596~)otA55|8Ug<#}F)T+;iHrn=c4bkjCu!1kO>dA8Q(wS07y=nX>owh(J4xy@x? z^-?eAPOH!ZR=_odL6-i?13G{@K`o}7HG9Rev`EIFLs5O5v-4ADyf&1Hc53ie>?8kq zx^zk8&}b8^ig8Uo3~;rfeYD(sxDoEEu<^zRXR3vOAV6nU?vw~8!SH!)qWX(pQ40zV zn5$v3-}6sn*R2#?cYiu~yhwNX(jG5RkAg&o%w|VXVg4qbUQhXshycp;XT40)XMH~K!wL@&EJjmLa_ zOrh1oLm!xB_j~@9zCelu&`$Z}saIAC>*02ci*qqz`y_u&CGy_Ebz2Cg@Fg69>s9v4~&R20* z`W$p?$Qls!ozJX16OL4C!khxyBV_7l&#t6wVsG@wP0!I=5rotO2HY-59h2r!E_&4% zZ{0;=?ok7F4(_E?nRIpH#x{i zYtUpSQzhi4q3+QeyT3r=%jY@|^?A|oyzY7>)h6P-s18Z~6`oqbnyNEfpmX4w=qHQU zd~Fo&^yMB(GrHKyy9TVLu`o1kr;2~f(1BpZwZ;wnmh9Qgr1r$9YtrTK1J3O|duzs= zLw|GL-;dZ_|N8RTNKCXXQnFv+&y~ENtbzN6tq9x}ML`2OqMZpy4T^Lu&nP$jvo2(u>o4MCvT(f9Y@rZNt1D2jwq09? zlG$E$bgZ$4>6Z2i*6hGq2S2pr@SeKJ;fAOllm8IG-+2(a^yiIapZs(fw_{Iz$N_=- z+&)Wv>o#z__K9?to;?aIqSvo`Do?uY@B+1`E4%Uq#evO_MR(rTkbhkQ}0HD&W~fOZ|?l? z-VTclaYmDIKzyQ8%y2nd8`HpF=N)^;e?_zJ6~xa1YKOC6k5AluLASjmf@w4~%FfKi z{JP&&xs2!FQxWKn58Lj=3fzWNkkF0C~#NxNod_*~cfxq{})skcb^q%sW% zp+V#Uc$s;n>RZzMR0#U;0!41Wz*B{*2h;{f8cWeejXZ0YN!Tzk&axWfcwb5^yX7$Z z?2>se_19TS;KIe}^QdbRDjTSRz_TOS3@*?U*h67{ya%u7!gQWSaC9u)_yO#_5DB{_L|qRqd&t7`t8Y)YhUwCLHQ!QlqnVAfq4^Kgp*0J0zIZmSBMr1~Sw9;CWC5RE|S@`Z( z_+||3IbmbrqO{R^3p7v;iNy*{XP&y0bSCIwa2gY+WRt#zc7iv-J?qYJe&o|uqIa%}cjkS@zc>|m32WiiMjX~+>f4VQzfKPD`ARfk1hXdI^bmggLmvd- zC?t`-cgKEt#{ajz&4SzeZ(sY=Z7jYIx%y7*yCu8Z{nw*Tmp}Ok*Wye@=O52C;L4fk zZUAm!uUgDa%Y=A=rwx)zpCA15yN@6Y1u<~Zbd8`a00n}*hVYV z005mDxX>=e>-&uxe2$U`?bUA$g~NvVGPgJt)HZPqX>dIP$YSe|#of16?I+}*re4|M z0#USm8_>K;v%RR*gkgX%_|ta#w*$#l%kx0Goy003*`uw*a-c&jSqj4>M^j;}EEr=s zY2yOc4`sKp5@mMM*l4g)@!xl3q6{pewY#2N#9umgeUn0vSL(C0?_Qu4@aF-3^8($A zi{{l8+g&pe*%7jSQ=ZUI9><7YlEYKS4m~?#nX7PS#>>zZf1tNlZiY^f}5t+(Eqyr z^QjD^Ody~c^~VOPsqxDw_#6Mrf=o{J7NPBQF3>=T@-rNFxc1v7{9&8`WKoq|Z&StK z06yy0hNrf}Fe{CO|o7HV+MMQ}6;Xx?t=)z;|KZTnjZ z*|mqYh$(xcqBH#0tnE2GjqG1`Std8lSwzAc7cG`dDBy52=lRZS8w!UH#EAx#9bEq+ zg4iBbx;CZj$_8pA+@?@9jR1hSAZy@#0Xs|q*SncsBpHHzgY(YR0aIC6Dw1s}BLM@q zfb8-;I~$Q2uxI`|_0Hly4Zr`w%TOvKW~6ff;sCUr%YIb$GP&TwNbDvS<@6Q^8k(PW z`8CcXV+)d1eYOiey;sR8e&LR!w$i_=E%O{OR_%ShHFhC`rnVsen(R%1#vawUwMM&A zFMS`bz#amBRh+$ascAx)$OV3qNN#=GWu@|hh3VCp>HI*^O^w~? z-8r^{mj61LsxKYnuB*r@U6Y-}Z90K7NcZeE!AcqCbGy{N%KhZEe&6!JUUQGuzJ;ps zN8)EIEnJ*X1m008X60kFhS{ut#l+rz4{zr^*et=t_uidEk z?uL&7WAI69%%_xYP5KJqyP1omlfu0|v$dlO^q@0n+u6?oN)mUzIn=_;7x5f+!mP}m z%7NL?rOV4F5$2!mW;sI%slSewo6OE1S*H7KKVKXrS}Hh8#zcm6<6v;Mtb53DuvbMoKar^_!91{WCr!pHZRrwcX3gEK(}t~M-0majcW z*USN>zkiDENIk@NkI?>^Dzn+4qVswTu)vmITN-17M#ht+(H{@hRF3uLFgn~C-q%Keh<>EJ z`nub8-`{F+daFhF*i%p;w4~uifEYO8H+qNXp2bTFk@E_{Z#Rv^f&{h(e~`Iqkt)Sk zMHstLsSlkFxv?MtG?=(-gPKS#3h{%lbV{3%TApB-O$Ta9ix|4!^$6FWI+2YNOOikDwTLayqD}U^ETZ0iQ}*5L-i;?g_gZV>%HQUN|gS<%}%Mp zG%u;(H=C}OrZ|+7&=K{8lCFC5u?QWF5G3xE*r#_QxufpQ$1*D=T^A@0@Pb%RftQkP zJS$XhUgG*u-4%J=*GTt;Ww49@cx|a_W0Bm?W+ET;kG!L5MJKXu$QLImvl=*R4X4h) z*gd+vVNPO@AP~OgBC5x_R23m zhQN6bDpJTtdrb|#5ONKqN@xCM4X)p~^pICYH+KmJ7ARY&FiJgS0J^4EwCU#Zg86dF z!v`Gm50#MZHaz))wjnk(WisErMa?&^N%f|xH!OT`AaDfkTGwlZMG$RW*oc}HLSryQ zX2pk+o}x(+6%6PxX1)8Oh8L70U%0t-Cet+m(^p*39(ue3xu$jL30B3#DTel5$(`MO zKgR(f`b}d-)&8H*4A=N-{xH5<`7|XBLoV6x4LEq{8r(AXXyQRg6r$s3amQmzWt&C~ zq6xvA9>Djy=e>t9*5iqkjzLr3lMBH`pByuXKsJk`!pZDhOosh;yw*DTh$`)AEPGaL zRAO#w)-E6&UW#_ea=EPDg6ewxtE)*D^Og935F~jI@dwi{YOKd@k|!5*dGk9>ga8E> z#Pey+Ja*lJrxXc|0hbNX9q~aUc4P~GcNv5Da^omR_)sZCG{hdEOcdS(F}O1-0;+Kn z-JB|sJAZRitB}2OJuj}u&IKkS9g|!@Fu~ChAd=k7P~lFs?JU1&^fZ4cTj@U9uc0#* zB5$ANYI;3CW!474I&#y)B+fJv+131)!#l7-jJ_vqX&2vG zf%Sq4BjlR`I0IJRYjbQ*mrjkd=w|x_2DphoQe@~XmgjTwfdt*8V~yR9Q{;ka=$Egg z$x5tCO9uFfr5GbxixyoRpD>*lt8)g3QNdPQ|Aoe5Z*~wxfMdoj_unKpz$diux?j;yp`xx#N=o&fF^SuTym8DcY3*e z8O!xMUek#?x_nhJ^Al$2EMR-P6jx9j^5fe82USs zm*pb)9S!e(ubVOf1+%3eXiJ^pTrlMm^*^bp;n-DqT7iZ6rlEuXk?hR+Naz zVqR@dV41J`nKmsvgU>jr%fl`icP5ehb1>nF=z2@0F(-lb1ZH0xX}3c1mV!<6qBlgK zdE&;6>+kWtxF(+|);&DwnhP*zo%?~ul8Pu4zds3p;k|lgLxpONYEQCK{q9X|kfnqB zHGsgxqv0RHi`EIi77w~!8Cnvb)C^D&(%ULUS>`(P>olZFOOJU)vgOfMG=M+|N%h?d z1nNbEG}uSEr5JG52NlBnRADBZqg1)lmEK!%ZLt~`NJqC!(uzk**`f0QXE@GSw)+(YIDMr zs(d_B-wCx3uejvc*m2f3SgvY0-Ia65mBfSu3sk>P<;_DARijJTykP*kj>RE<;4y18 zC$%P(b}f&JTjg#gm*jJ=Ddu{}^$o~&>;D0D-b3nIdcnGXZoCFZ`zlRf7PyXE`!pmx zsaWfr-dbW3m~rK4l`ljUHBI&gVxblVv&WIkjR6y(4>n(CUdT4Dl$}J>U$>8|aC;)? z*M}I>6C2FKxp@S>lXncqZ$Sojn6Qv-wUgs|7+NPBugpOFNmj8Y1eCjteAF(R#8X@J ztDq}i9n=K;S+eb7TB}go^1{jl%J{S1TFZhiJ2;KB;#HOqXd~@0DR+#pXgTmM8(7bo zTxX$(gojJlNZNvWxnbYK_I6 zv_xE^>^f}6$p}*%*v@!n{VL_YCE`sV20%xTw3V~YDYJnT;eq|VxaF2uok0F9pbxPk zwYfxt@`Z4EZ>RjUq2Or5x}}`C7XR`}bz3>wC&l)b75i3ADg<2j1EW*BVj)z7nNx$5 z&BO+}QhIKXTwxrsJKq0!3o_?|wcDPOCEL+=?}3G21sv|KAs)}r0IW86PVUv7%c(sDP#p9L%` zk-#fD@MUC3+7`rL)_6>0tBr)`7gWh%A^h^w^Po4h=ji&alAX;(K=Sev#e8zGWm$v0 zc;zf6JCk$TU9}sz=IS|Vmw+WJxjV*2AN9y)p@KpE*C>bULlt{Hp){HCmr}jCSEbo) z@=mY9;n}ls?vj<`&lywpuziw%MKGch8v~{@pOJB^xfc+7>Nzx>eNr3xZF%BS_O%#Z z{Dj#`n=fN8#tzo_3LlW^Z*dMtKbT)RY+gh;BYwW`Wxdx7l0;Q;4|pQ!8@X~Ou&ukj zp+09tZF<(FNj3r9dDxa|GHY@+^#Y+YT@sp|sk9(laK)JEX(RZs8d#IjbPzI71R zWWg?aKeEVz@K}(dfhaf6>CXAf+1>(}=tO&odb%$mMa_8^%Dj@_4z2|)X&N%fu3Q@+ zA6!z&bUuhWA`c~vd|6O0OV=3sKoTx99C+(>#jQ52IdVrJDrUNrJX1I2S5sU*0z|p7!78~2~>^1}+twvf+U5j`H z5s4)A*O*|>FlL4X)il_KHpIzd8+gj7&C0L{pN6D!qz-gpAB^SI;ZJQaa2XN5^{ zzEKS`?&G=9sD_AGMx}vcBJZr)Ft<%OQK;PKh0ENk5uOS@J#2Y!`)aq{P~J@rIOCe8cf+fr1Ygh%H#EP(7 z=yT2G01xy>B{Ktlpg6Yogz-RLx%5&fV?NM%Hv}!UG=CL0+oQ8II#cdoi1}8KgJIY^ zTgO8zXA1MbJ+Hr9*HFr6;hofI4}D&)T37PJwS9dD;A7hzXYtIJy0#Uk=Ic`CJGuI3 zsdXUYtf{lQ`}BYh6;(oSQ@f~kOSwsQ=_Wn@5AUAo#~azRFJ%{!k0LI&VTWG!l&3D# zx373POKZDN+r%CJz8XlHt#`J}nsrIc#p!gOTpv|` zo5Z>%jhT*|_BmoC&N9Tg33#s#>}|L8cmENcRtK9NKp_KwZfZxTanpXx8A zn0ljtn*LF&c#0$nZOd!Ik<;+k@i5k5g&ou2G)x~13zh7D&a2@R7uy`WHLyoV?*M1z zgxcK7F=#Dg|7_-c#V6)A7KF2HcC4AO(>a<8&j>0#r0#?-5ca@fuT)7iy4#*NV0Tz) zrH5+KIZvuM@7lrx>NS}<$P+;{v_-{G+ZWoj@w~6$mN{6I(P~#U`4}Qie+Ic?^GU^} zup2V8qBl6LrG|@hfa6lSmu>57ir59V=xztgTnC9po7B@L>UWsOuRSVwmO^fk^)h>v z8`aJwzaJ*?z2Zn^M1-u)VX9x3wD}5j`{x(NI(=^mpG&F#*b7il3a~EyYNRki+m8&F&RZf?SoW>Y4MI{%Jh9ztjlE|^-xH+F~Ih6Bp zV~$}Pwl#cTdcUvF_jCOP-(S8zx!qp7-CnPG?)iE?AJ6;!alhZy#tS#h*k0FH@(}_9 zVt+207mpxfrww7i6T=H)%F#^kY_Y^V0tFgzjs z1WAe~>=W42K*_Qvq1MFC9elBZn39wqGIT7wyr}>D#_AWn={fKZiF?!h$g4g*SlbrR zG_~PqSy}sBI-dMRHG91HQxe@vzXf@(7nee!4eq6I+rQtikT>UQx@ES&EM8)AybD=e z(0Xx`q&+CKN+{rfM zI(t@6lUFSV4JhtT)Lt#-oQxv2BOPU&fMNvmaZh$TtRjCvns*z%v|< zB`yURqOSRjaK5zI446j;7#0sPs$>`GQV~b0I(Zkr@8na@+M(5+ItWEp?Bnq+jUGqm z4%56suj`0E zUa|PXfDng2ea%=7?1YlwZZy)+!j&z2*=) z+T^zpKUQ(Ag`F8(*&A)PWGS1WS;~e!ozaT73@vKK+Xkej-MlciP_KWPPu$47>bEGV z$QZmr;@ziYjbN{sBbx90v5wd|@mcK@1-BJwo<4iqQ1sTIz?rA|Yi(cgUHeS5Dxi!# zrf3_6t+xI%M*Ma3=>F$#VjVeIPtzp;9jo4SH>q;g%9y}}ZFUB^+7i9iB-Vuw<&r9( zqz+pd#g26E9&CXokZJAvf|}o&1q+wlSn}@eaObP$xP11U3m$S&Q%s=F4rqB8ZtewE ze}k4kZ6ZTNYz`{U?Ha78;jg4mC-ojk^DK0X(3NI?LIuddE46oM$XtZ={S z?#XQb)^$dY39D4aF9c!GpL-yP%feD<6AvZ*!KNcE&iYgY z>?3<{1*(x5ot~DRkl*R)4kLh;c_ri6>nU6JvI+T^S`TPM!|g$>Q2+hPLvs#pW{Tmw z?~gP1n0U=KV(VGb%2#At5K(zB6%f!pjdh9OBse}d&Yw4*Z!?%N{&K*`(mAnztOBe% zlgP%9Ct7^B=*Bci*<$tOwza5}q%OTZM1>C}HHQ9W%+JR9uH$HF?n8mPZ~ z43G13c2EZApHQ{UZeKEH7okccv^Plg*$MXBjM{jp?+AMzT!#^`%m4cmfj(wclBbZ^ z_9=Z!jVXANj%r!_yr33!92}c5^tY!)UOCXRr8uu(_hy6)wA5d#g^x#hB1r+I!~5 zvZKR;1Kah-x4NMlUC?oc294_)H{~L5Jo>q>Lut6XD@TFjYLl(_ow2*Rv{39xn?}1cprBd2J9Q`v zmqO#EdwFkU znlU!9`F^)yMdE;`2PrP?L6#Op+EwKG@Zx=U57IsjNSUBLR$ktTag@)ii@*rIyvmyb zt+VVs$z3q)MFVoQ$Z+8FD|OPZ9)x|QJKt=Mkj96>IS6u8;?`sNVej&?%=e=`Nb+AY z?hA~^lXR{b94?>;w|zQ=12jy_=-U|N#A7Zb$7Uc(3a*1aZ9Z)U&Dzys{_m&2-^3=zn=aWTn&7W}~J@6xBavhel6&ftjFeENs< zYBc}tva?Kkmlw+MTJ+%iL((DL)8Y4((9;s3D=jS{f6{eVEi)G1BdE?}8*wq~a<+UI zGcC`B&4jL?LOOmGCmZqDwimQ%1XMA`Ox$EpoQHnz+|ZAbtO&-@>Lb2Nv}wlb8@t~K zabWxCesgclmVf#zZte#v{$E``6DsO3W@xj{eO=by-uY{FAC3?3!JURXBF4Df4Y_q;bI$ZeSt)8x?4f+({qzWQ5%<&g{*c)>{Zwc; zZ!?UP81F=W-taITRU+tx1Q3hVL@n-{`l7#=*yj7rC-=TiC!v+IN8&s#Qs+E6B$O1( zi-H$Ed?veI>|FYzv!A-5Ge=teCTYB?&AwZ9L5nhUw?#%fCT`21GuEx1w?6DUzL(oe zun*>B`Q*A_YLe0lGd%VN>(FZAS;89PCWzmX7NoTJZbVsRWfuY+=h41;3e;&EG}IG2 z_a1XWo!yT8N`z6Xri&FxZs8oJMXa&KOcBbkApqqFzj1UcOwoh4V4n>t&bDbU56Q5&^L`(x#|EV9)9eo}bXnSTFL6tW{tO|*8lTBRF~;oZeuDX5@DpcvBPmufxhqs?C0?v)Hx z;LOq;jeq~Z!=o9QVbV_Ddhg|*{v#*D3_9GN#j&nn_d!WgMshho@e|%2umnREE_PA39vLp~y1JRh3P;8NW+JLuVDGg)l5@Jvw>O#>TxM)+!*V8^7wvy1snd zqwE6Q9;K}}cQkKg0ZF~MxDqSDy4K$22}#%$aip+I6L7G=Rf8nVPHZ)L;4W8TG5~RF zMG8lot?O9iM`m1-*5)18lA{c?g$27N_gb3&WGeHI_ze{!Q}wSc?}u!6P{zn0@LIvFy9qVsfvnGO+<7OdOmC9Rj+e}Dx{LZ z$Z`Y+?@AiEo#h63DbQA$SL9Yj^!g?MAu zoG+u-7)?jaqiXChg3A$%YXV&@6lKbeo6mNcxuDJxJC+i94LHsOjJzTFqAtS+dM_$% zPkRz@>IlqYBYZ>`*AA=D4Tkw&-2nUnz)n8D}=`eZ_Dd zHuQ|W*K6<5LK6fHM=|qGUikEaffObT_SH9;YYOC$J*r+0%!YTT`Nw!KW`zstk@5ko z=@8B7w#Me;yw03iBsCBCIC(2Oe9?8Jm-0b2*MZ+5sga&i*n1VTh!|=jvp5LyM@mux zWkJ8^#L3|%5@Qnn@W;=(M;+kLPSxor_EvY#JkId)W%RK2!OH^7+MjCrE@~t=vSzKx z8Q7ue#~JcB1%DG;o307aEnJq!)IKPSbK`^=FAE|7{)^YYGVDuHhc zGOQRYgrTP*non-MjVK239DNCec%&WK1}V7Na$-q`^f`^0Xh~epTm16KQlf7Ew&IZC zjZd*u=y>kPh2GThPTLr^qg*Ts^?pR-#v2M=? z&_KNkH-lB3JhXfVFuRao0&BEAXVhlR99PI1nOMI(ZeUckgw!wS4gNAchhRmmGtVzY zZ#Z+z{iaI?(NXM&wH|pj>oDkmxIB1h96xIOVA6STG_QZjr{(TvR*PXF+RH<+f7XI* z<6}=AULE>?U0nLAe-XLUkY{HMFuKk>ml36u+7l`FYrm+R!VjN5ycZCzpCR8h9FPBR zLqo0c4$wxby>SoVezp!49b55ocn%i1hQ5hUsNeb@4r|~ra69y)yn28Gi;gid?nxA! z;@v?0msXqjU;4aUBB`!MP=KLT3 zBJC9R0uPdS9QGmQ9}BUcdW6?I<@0NW6oc#ky1j@QKvP6bI_LfC=6=U^;P_+F**5>X zYa^+)4yZ6DZxuvWYyZC*?b_A-`s_|! zh{FFBRJ0%_zP5~W`n)kio;mwkkel-#s8IWzw$;-C?N4K@( z$x!y`?NfSwDfRaf8j#BPr;O2LI@aM)^!eLRQa}a|kDc?;C(mEk&hI|`(?ZX(+X=~@ zTr8z2E$JAGbe-MoTF|0X7N{RQ?`P5@#kFJ){*DN@xp5jkRM(A^Uz*j;6 zuGHL50N{@4I@P>;pNC+TK!2cl0cnO7C26eB-vDe+tMlQL?>=BICEUMpDgx;Dd%7N| z;9DXZ1_UYS3eD_T1V$~{0^O84@OZmzpW$qZ0EjS2dkkuh0JGh}WkAQLKDRH&_^c3T zC$m9*=9=`SQl%|N^q`x|qE-dtd0m@8Jfg0Ky>zfj#yMp23u=c2T>Y&4Wj!H6K$ndO zExmM#3N$Ewe@5ZY=b2L|a^vl7emU~$negw@$6Kmyw%t8u$ldApEcnqP={T#uT+P0y zoThj+XyKq-ey@Z~z5?f6e__&x-csari>}Ym3(FneayAWj8&4Kh*j+1RT!$m>l@$q? z@w$>h|Hlge8S*82o3Z=TOy_F6N znNEYTJ59*ROEn7k`F}Jm*LVK;$sS8>Mbo(-K==Fs<&mI%r?fhUUn+_Xg*_ACTilcf z=Z0EON*J>e6XI&?B;Ks=2;n<8w0qw&#Fd0k@1&QQ?_d2|hzk-jab+$il!u-ldr-RRGcwN3q>z@!4S)=kmlKO!wlSlO% z987|AdVN~|^qK+7w2h0kAuxqFh@pTBIE7Woy;4djsQJ?cb`q<`eilJ2I#vY;wc;1n zObI4}d%j`A6Fh!D1&7UV7aaVLgI^(KClED?dGGV++qOMWQ@+C(E2a3@DJ@2qXZ0BW z+vj);7l1+UeMFv>;Vp~(Vzgu6_ZYOJ0-Nx5KA+IPy<^|^8XVh}5WeW*LUtwMuWtbA zx$tVOR(ilQ1a{B6|892rJv+l+*EKMic~NNZueB_VNJXRR!6uXxnB-L3DZ3Ww6l3$? zZH9?y>if|<+LoG~-SWp~VN+68_#DPyy0 zd9>&LS#XWYPI=~BrlSr;7t*WFcgpLvA!`+T*f7sV+jH_dyq+hlraGDPPqX{g#UHI8 z-O{w^vtNAaFE;iD6-;xcU(xD(nvHO&+JU(_G;CeXKY|m9ziX+P)ln=z(4zWEk*!xW zq$=%nw)2E?MQRl$Y)W~h-6LhRacTe7ga}-=?g8z2-se&+(G@w5x;Hxfop7CMZQ$o5uepQtn zvYzT)>qk;PkjlYNh7}T+KuGfS7(O}At(bxTst|HoGxfaLXlii+RqiU}2 z)`weaD;cDaznE307GF}X&3F-Seu;_l!i8STEMyE+08-(Wg@%PVV;z3W`g2MsqLH&! z=0bmGB*DM)pgz7huweXozchw@pSlzBl({|MTH=e7m8tOWyc=!xPgiET)sCs$saCgOoWiE-{1pL7m2b0RjtdD@gyDbPP3o6;nuTI-E_Rx>^ zHnF~I>N()>w2s{$#8zgHFu(X(+)qVk-j0sAi?^{7ek4>%x8Rpb!IwH&iloioA)1}? zrbTRF{Gxk0=X3zI_!P2e{~y08u^Dx5EDV?0+_wJwY}FlgflpYydNCo&@nHC1E)^DU zRKcq+TlKdt-15x4H&Y%E{%W|67>a6vD)JW{!@|F&rE zN&l$ZGI01~nk4pbNqfI?^g~SGv!Hy-p;?fG5T-=mV(x@|u~c|Lm&{gc@fREUn6ZEQ zKlS(S7#XRUK2UD-Xxe=N70|Fgcn^X`2n%08uRHE&JY^vvhEqb~hRZ&j&x*qqfu$Q> zdYqY320CT;iO9$dbv^(Hj(Qjp7t5s=drv5rM70(61MM%W`-^q80rSW|A9U=vMc(Nz ztPnlF`SNMP-GMttb3hz1KWo`+8kh7Y ztEAwo(zVcaml3X3QH$B_1pM;;M&j1*5c+J@VZOJ(U(a3WHUga_FK&)odZOSXghqE- zuc7YJY>eeLZGCfy)x6_3A$IsUxd?8Pc|am0flk>P0lFp{d;!XfLH_kg8KL8qx*AdTl9&lv-US#+GnS%2Eu$mVT`VD^dPdT?HJ!N+kbg6|ScY2N1&^I_${6?rLW zRY)Q#iz2v|BN?C7TJhG`9hAzLA2{8-D4!Qcxo!J{n`7(kC=U{!Nq zb()l}UF=J8P-gq=v!vpC+hxE3%!H$e4SgWxS+A8T|U- zy@SszG1ljC*Nul?M^jfCA4n2=iWY_vi*0NkhsVm3PeAlIw}=06dlj*mXw}*;WCY_#+sIOG`+r)G>`ot1c4?bqjz9J_uzDmZ3_7$ii0zMVWK^ zO+>E%1xK4l+8g0iuPBTU*w=yjr>gYt8EO9V7 zF-A*@&vBm11N#yNhM$sd%8HadUBxA&ZFPqC1fxrDOTD|?{1Dmk&|hMWp^$fQl&CN} zyd&!CT5V~GuRI+AnEM+xJA^%WpW(%i`|xVqW9(@zSeWyoO7K+#HBhb2Y>UcZ!88tB zV(TJaF22gkF?V2hR&S(Y>5IA>oK(ZbypG67qpen5@J_l|oYsQ4;ZT7k=ZC}rxNpUD z$^q{m*H!y>9vHp3BOPuCMojq8!4Vns=etd3VzyR9B-duTVFhwBI;+E8f}M!C*|h%b z(;JMg3h)zWFJ7{0_-oaX);kkzdzy*Lvjp-#@|s%H1NtT6!T{|23t)&=7c-&!>)#8) zdOkDr!qEBMze{L5c<7j+igw=|&Y&%2LP8mfVn?Qhj%^(kRV8h`lW7#a^hURAtuFDr zx+!9)vDsUjW!Uv8NG%&wF?Nq^3tv^PLef)|;ohh0)F&Fhz7P%Fokwj@M%`_@Fg)9+ z#`ZW&8c5P^fp2!fx{X;Jmm`pbuE{=+PN!1q1hkMFOL~uMUv>mv^F_8LsBJnZvEqER zss^2+ncyT&PtWOk4>RCJqKIncHsfnwC2G9Lo|$l9x`nq1slbH4Qx*BIkVAmy?x4EM zf!nWNb$P=>r?lZ*w*!;Pg*KG62gbO6bPlzq2lbx=!>}R@#rcjs#n{a6b`F5xOcUfk zPaGjluaE9t!}$_BcWlS)o5&>bGYb64OH;P-N%xY)Zwp`DG1xWV7KZY_Z}2|<7GKr_ z?H5BUM84up(0zJ*1ZQrb3kC-g{Cg!$-*In6sV#-Qfdd*KeoYXxnKk#RN#roVCkX$hlV^9^L0bA*C|v~;pf21AWaQ#Xg}+Bj-&5xz6~C8oFWADRUkExZiMp5PL7;EDgFM0gS`U&@7O(2fO%G-i zGlxvI_`QVgl>yRhuR2pmuFeu&yE`TeFp!KWc>WJ@oI}gz<1S?u3x>hZ+6~o__Xfuv<^*&|A^HXl~!K7?A=@U z7*lJ9#>3=JCG@S1!@y}xzYf|vLk)%8EFAi0l1^XR)+KkW4?XL)nUa^|Rh}nc@|>J^ zxqhPO1&BZ^9)32?Wy(Zkc#-<8u}MICi6v&V{ZpHMT)RueU2uOJJvsqmuRM{vS+g-_ z=!|NP0v#Moy%%`qABhwG!p=f>P(l1uibWCQv2}CdhG+qnc=Ocl9NIp|`^wx^j4$NJ zNS~B9BlEXN3BK^INL|Cco#JeV&}QpV0Z*XBfs2~y(F0oI?lvJ+(QSmccMsMX2naapm| zSw3^%PS`Pe6oLE-hJS4+`;ngeRM&Ko~xJ*v?%rR{_w-j}?rtLH7i!S$0!}I*VN(^yd4+MgqdXwRorzn$G(W zr)9`?h52Vj`Jmc7wx$`a!@0p-Hv;(FS|#=Qr0&xq1I=CW6bx~BeHNSwb#v~M~!&-mAMH@UXmcP7lx=nVaNj z{%XGH34#lQ56W#mA$jK%&iBHZfFbOcv?wP7kD5S@7KF5$Kzou4$kDkgo-Aj;C z{EYFAw$5kLSkW0Chcy)@u99jN}+0Z20qBj@d#8Z72P>emGq9 zgjf+>bu=>NRFZFU>)m4$Qh)rG1+#xm0v%MG_rD(RAfy^J_+S&rtA!CCKWw>yyX(W0 zTb7c8lxJ}J#R+#W{_a)Dbv1XU%uU$w-^K4Mlr$~H{mInj=N&}G(RM6)yS2Av7~7wN znY13oAjKBnR&wIg{Ut?5e>-HkSNYnTKP<%lXd}%hq-4YG-LC!>Q<7_PB+_2gVn9et z?4fWZEe}t_Q~hUrbZOx_!NyKOKBV5wrR9y`WoMfWs4i-58Xp=n$Z^`WRGSj|d!b-$ zrqSU+oH@qR4*eCs&)24PCsrUTXZwD>^O)MD4JRQwyKrm1@Nt^{@tiRM+smWCA9CY# ziJWo{5y(W6(bAd7gv7RY7jFLk4=^6Ef1qk#WZI{OBg(P(qg+i^qqLl~q_n2bymV(p znP17xqF>t1S^f?!zHr(5i`%cy)5x3w`ytv{Ioxm3DuNr$rW66J?c>00+L5S2X}R_i z3!D7vz-b^B!7QW*4}Md8fk;)Dtn+bAa*!FgYhIJ#p#12y*P^C`OYk?T?N(@C-Cg(a46QAvNr6K)C1| z4<&UfFq%BjvpIHrZ=_N;4xJY7gt8T<=Dh6s;ijZb4 zU+Q!yZx`{kro;ZB4hVnO9+ojKt;QV<$jx`YSR;MW{$2U&!ry%Q*g`5S=`9VCJf+Em zG1q>i``pcn*#lK&MTbkSl!n}txl15%ZozK8C~6^N#nTzRs!m%y+872q>AKTw>Fom^ z-CEMK`?)eSn)D`ttZm2C z3Y*i;V|=Zv?@#2SzK;JTW_&LQg?%<1_;?@%vK7QH zxqow}&=T2xvXCH7aKlL->)-_SFK{ycpc!nUDw`5%H^PoA1+SfSSL{)g{sX)ZyoTyU z8@5yMd^1w0M+55jJ*5cnhw(eCq2_Jt)?ux*UE>)O3zY$L{duf=d$C(YWQ=bpQEVssGi5mG=o(nwIxH%!|-`r8%H^ zh~4<(-R?p1?XbLnBIUA)vX!pht?Ft0d&BR%^hBj+q}PV?;ZAvDzbnivmo55Q((aJ; z3jZrqqyAkx7A`WGU;B-kCdSZRF+QQ?T0HpZYZ|oXaKXo9^yM_S$&<$S!Uh$npgD^l zl*985|5y4pX7$Q<|5#_H9pQP)i3+%p!|>j)j7Ay{-vy=%02Me zPqmLKGx!(9RwM>o)_dJw@~^9YfcIFGANBW-U;l@F{ApHY4O}h~t4aA^+I?VvQ5Rpy zKSX_d^}*&}q@MRV;Ij4XC+~ktu0QHG(AqIjXsu+mpD-WUwyKW*qjw|5WQNcw znW{bZ*Ipfxn}7^_Mrdp*EkQXl5eB{&jF&3Ynf2Op6*d)Fpngg_z(oooc4lgk2?R3+ zq<*VzZC`R8K2*)=u@sRm1~xn2vNDTPgEfF#n-UrcnAq)tMDG~EiB#l%U`@laayNjo zIqWuTC!qib`~l$oPbqopz;W0?BB=@>Y4!-Q+BG8jW~N}L9IlH9I1Y|G@{YRYp8*bV z->q50Nj6uP zb?siVAB6hI69dPNEE_NRjqk^?@T9mO*{CHWTEq};FGN1gwEX?tIb)4Paptk?cl!69Fr@2Hws{~7UOp4(_O>&CmZ zV#D!W+-i%P{lww2(wKz#S^ci+WPWyanNzm;t?x2=d(r|%v&bhMgqV+N$L{UlQ3w^! zavIytFYT)fxSB53+{NCpvo7}4=ll$UCt-WpKpYX$5xa0^F}2fMfq^VYlLKjzcfzA4 zX$d$^=49`Vi{o~SOD~n00{K<_9=}PO%$>UYiDktJ9>XwKmZu3wCEQWxvagqq`o3#(nlQ;|eK>Zl4bQFt)- z+@1O0>evn)vJS_m4hWelWc!b@wPkju$ z30a!d2KVqXn|v+_&94qKCj*h4U;o zi%~=RxJI8vHKYDjU7+35IflSu=oGkr)1dUpZ^o#~L_AlLh{2QNJknU-c&QxvI%(i) zyl=bb3AWrG&(B;LtzvxWv;k`}aiPy}?Fp81!8o8hjU}*h#uDkR=Dw_envZ5Ur?889 z1~;0DWk^%Z=e8_W(5MA1Xr9uZa%Ro%SkB&XjT3NYcLT`0J@G(*qETTsIZ;Xe=l0_l zy7NB2xSASA1X$+PKgQ3g=^@i z3Q!1tb4L;DRXO6y8r=iY(ZhbbmTL9IzKFR`WP6|suM;4Mn9t3*m=2M`u)d3l8@9iT z1)mnIyfqmDGP58)Zx+jMzzuk{H;A0V_n!qjK!+JM?U%LAuKAtRo(nemp}Wi|f>X)l z<3(NCarI3Vfg?=?tM&Gq+#{D7=*2Bc@@PhB3<35aeCs%Shgarq?yzC4iC~%V#dmh| zEoIobNbr5vH4wVjN0q$>7Yskt&EQd+0evnc57!H&U_Ex}lA5j~*61V@E(_VYd0e59 z|JWe3UQ_yGT8WVKJoNLA5~H@>gfL^nMZ<&L2)nV7+%%U#iLu&5)E5wShxxqF+}#k5 zd%YEyxRIe0)D|4ps^62)i{od%!U`sGmiOeglx)9)Zw}r022b+lMsF|hRfyg+I)T#T zcaz_C7)RZ7{2f{uC$c2(9QaO>JQOU~N_@CcQoT@tGykOR0G@+4n@5&#qMrt?{h`hN zxZZT3e2c#zBC>f-aqv*9etkprdpN`Js__qMyv|})#7jH>4Mmm86?JpM{RaZBCH1T# zD-L0hT?zrY+Vl|T^C({ohwo@FrLGoLlTI?zdO00eC(X7dfHB#Kt1Q)?h`T;>(o^4> zha4&s-s&P-*g4yu{^6R=y!BGGj8P@+p2m2TbU0@(l(~8WZT>(!A`2g^dXQmQ-^TL} z7FDDp7R{3+%p(N@lP;`mx|($TeeWJdbkrHQIb~HNeiA|bT|z*&*~OY{@q&}*zC9{R zCZSIMxI3NE+aiK?ht4IEpp8VK{m{v~g{1k>84u{r zuo+NG%iHjvl$7d$!#NVG(dlQ5nK0I56RjdVIjA*w#gI9FaQiNT$nqrEQ_i^En3E(- z4(y5O(OZo^UdcP!hz#ga6x`xlpx1TD(a{T8-HCzIH+J*9spzck)5D2nTCJ9t)8XfO zGIE@|t!-|5e0wTlKUbg5bR>s1U2M-8cPBpwk>22q-F9Op&04PMp|eUYk#{k2zJdDg z5p7-3Shu@T`DY$DGg$#o!`ksrVHH`i*|dI(*a*OV-K}23U@*a#`iQ%LUAN@lL8iTjwS!=+9_NvmrQ<84^#CTK-gHnjDGjJ^0NDYb1zSbcpwZ9sjr z_X;9}YeNX6JU9Ieew`Ya%*^pZn6!n}x4-3!jmciJi`PO=_cDj@F8;(;pe19pp?^Sy zM)G5+{cXe_gxe?=DpU~_#5gV5Jd9A>;TocFmBuQvlgtl`jE?3XXTlWvRAJfT-tCC+ z*?xTfCs%itVO4Xh@KEgK`gM;R0OKm zglTx~1WV5NLBYa$)C<@Q)OSM9aydP|PE4ed&46vfu(~*&?!s3If#r5vnrLMvNM_SS zv5}iEo?E4}AOx36+1xK_5QoIQBz%-Xa+Tn{N`V)QH`8H6!B_i_~taoh%dvV zue^21jfRIP<%IL-S3cu8q#_qfd*eSN)@b@`GnY4?iqG*Wq0m;3t#6dMIx*D}eMZ;2 z^;hPUXEq7Xa5-@%g@S3pd1Uhowr~j+vTT6}YbOwShQ68-UWm=}a%&Uji@mMl*`@_~Y2`PSZl0qbI@eln5{kty&zvDl4||VSV1xXq zzWt0w=)k2{gz7WJx9?J|wwgmwtnA%81So6x{*OO>q)l-)Z&`hJz6B>OUFXk=7BYJAm7Yiyc7kzj^hZ^6L8hu3 zov^@&u^o&01vZz{QDNe4oYj4Ryb3m8mY^_9ACt)xjWdAh4vGoAP4h>H{V3K79PZlsVM}vU=4Z4JmZA$VsRLS#hubyTiG(7%*g? zgxg(Re+!71yAa-2s8fCyBETwuIGf1MYJHe0|KJe;3rVu74^)tDr6KNHrx4$*bVwZy zg{@9TgEJPP!L0z+R@7s3^k81JKHhtN+5+M88>_G7ZKEj>2mF}Yh!?ZLORrjjq)Dvo zY3uXkG?j4`5-IwTpDT^mqwU6auY*DnTYaO@O(G{Z%%>9*W#$|{`U`I^=AF)b#;Z8+ z;#~tEgkbl`p#QPum^%Z7d3N)v&euWHTvHP6O<{3^w!+|snv}@eOus@B+w~8_V;zv` zUXNsiQIZ=Ck5bu;iVHv~a@)xqSQ`)w#jEk}9~Q+1H^SFk$3LlV9z*;w__T1Iw99;s z|2E)mf6u2dZYceJ;hCU*(3^zsyeh+a^T*Zup{J$K5%(v=_HP$Gm6FHo;tV`mZq9n@ zK6stEI2X+o=Y-V%Ud6p+yWwY$PdY&Wk1=+ij_BM){Zyw@t8X-bTZg zypLa8?N3A|tG3`~`Z-k%LwMqK^ne{mK}RY6rEikO6;cELsQu>LyH}LSS!b86v(9~2 z4d3M~41T}Le+m5Pex5&H%=7b)XHM~s>LZ-*;>*$=HVCJQY5KQ64_5o~soGQB_jr@&=j+0kCEWz(y=sF-r}>Tf_%iSdCxaCPYPO}qlnK0v(prL)4;A0jnd;b7)t$UD;sKzS=B>HQeZzZKZXaQ73)bJxq zR?7D*?EEQqkd~2(!5b7c)+(et{yU?q7#3PG0JTDEG1Es^f{puw*ZsAEZ^CK%Z#xnV z7`LjmvXt5+)3}M8hP(WPVRH0bsvMPxO8w|Ne~gN0UjbuviM`9SFBkMkIZEwW3g2A^ zUJL_~TM}WOBAA6pol?bsRYPX6)L@@>|5T95niyf_P48>XR7YAUsOjbW6#s@5R&8gsX+(d`^UgUSSEjiZ5aB zi%5pM+WbDt=(zJhlDzA+1QGkU(H!SYHy%Z{RL#Lrb+=flOm3O0y3#_#eGiR_t98~lcUGLDdtHm(whl|Y=3}q7r9G^1t3MmOt?xer?M&zhOCF<_ zX}e=|Kt?Td@WjJ;8wx3l9}Ca;zSTj524p~XkEylNf^|RYf)2VDP`_IH+4y)y__WOG zYr2Q!x_r;{+-L|?NFi)>HG6v`k6sU*;UA;xTi;A^awc8$=m5$_Z$hoJ@BUYvPltui zB3@W_wgsCh6JmzCnm)xqrkP@+*2nZx3<&)i+Tq4KFxMbG;RW0~G)SJ5(33raZ3MdU ze*+Av&ZXG&i%|!kDor~wpW&n*Amh1pI6?Ym?1(-0ko>AfaUf1~4xtNSPL@%}HQ%)+ zeh#@Y-iJ9xS!oI4t2ptCb*O}rRg1y&Sd>zRdDE&!`0WCEi8oM$kbmPHvn+pyl;C0Q z^jJMJ$_aZxt+Jp=1`KPf&SD2)rNE6o1%%TMi+_?bT?uXpC`A%?S>FPVNQDv?qrw-hKgwJ( z0Y9kPN#XU-sN3gQNj+-5D$m<&7n^TKwtIXtdQJGQn-&>kT_HFw>cmp?QK}eZ=q3}@ zoZr_X$gYu02VD4tN|);sd9SL$f2w;TT%CR_7SpPx&XEHB8LhwB3kIHjiI%Cr>N#u{ zD}%x^CyoM=J}NPUzgNF%Y^9vnf$EAd{lQ{uV*h@6J-sxsm%^os8N3zr`IK8Q3&Huk zy(I?axTzR$di*x>)fn$n3YdfqVBinT`Y~d{_+ZkEJbVAxgk)IYZ=BF0u@QWaW;4N5 zbVK2(+&rAi)g`e4FG~G+Qsv}1NnW2hyoHd@NtMK`Sx2-w=&=~r9&0_ok}Z>He(Xnu z#im;HPTqSHL~|9bL|oSU#L6Sw7AS|57p0@mx>o>~n3qb;P8hg@QyA+gDM0j)1pxOZ z225_Vl{I)s2t8@lCIJG%`V~m9PPJ~sZ`)SV8cUhVg;W+t#IwB@LE4(wm(CG{zCgbP zZ-%ZflrtASCCj=TIK_27z914kK$%|g&yJw zbM-B5AAkW85_Ts3ZQ9dX*W}xgXQATi8sWk24vH4;*2d%!ecWaM6y{P#FWxQbU zo#&`mIpC`8FVLIQ3MVcBi>A5r?@EC;mCIAKF2x$pMao8kK zY7kIqe0t7@gWz!a)5EI?IAf)6w6i<)c!?U#1XJJ%kw+imt{Q`mK0mg47dRZ4#U z)lKlUPIH_1kp1uB{B!f#T`HYH7N8^1X*d0}9_nu1Y%EN&ggr#8n)7DedNBfqW&Foj zFD64l5L=knnWjCU--Ru`Jnq>u(61G_?}=B?A+|Q^R$$KZbmh)_Fu21+om0ONVtozg zcK!ueMJgZJaO$f4Kvgj#_~lMqgk$=bR^C??L@e`mY6x+W^mYSGZuyBuz`I2~Q zn7D~2Ej0Gblu)NJteHJ^DQOS4ZIz86`WR?V1C(Tx8-!=8pT9+%2(i7}lft}`!2K}R ze&O3E8mA-;e0%aZ;?ki1DnE$I3@OOrOb5AUYwy~w;nj}rJGJ!cdn9%#x8`G-*P8T= zIeEEQcqzOCee+4YrJe-6Q!<_RcPZo-Pg}QM3d=Rw5qsEc7eAbE5i{~B3f#djL@HXq z3CnOpb4poTVM(WfZA(_}rJexe6%KvIJ# zsC!N0ajKpn)BVBe;+Rs`>LQP7wUxngOGCPQ`j<2${z&1;Q|;Ju!DB^lYu>QSPb)4K ztG<(T!k}&|Oa$HxV}U4cuZF9M{-;ovoo09!dDRs+?MPX%DDuaJP^uj~oOZ|A?}PDt zGV=8`5!`PqW>LEh2TYs}1HAYA40b@OQwPM<*V47SEokTQ@_&nUoOkPa&b*~$WPj|E z-3@0>74N)^Vd{u25mr<2;JLM*R=D%23j`LmsT+_P>Q=PN4ERxrst<6O+^E3NzI)$1 zbim-&%ns9wSDarVUZy4JudiOwTDSE%bZgb4AXsp{*)#z=XKBQG$ZbdvW_cEJ0a=!J zu`0u$GG*)J<`c)eEbyci2t@Z?j2?H4z|6~U7N>F=drqb}^_o!vbjADI#4V?@6q_^R zOoA7iNSa02Exvk+VvEmRq5yg-X)+3BstFGd_;xr|rJQRPeToo7j0)Eg9GKZMyBI24 zF?R9Ay_|y5?*r2g0y^CQo}Dv3@g~kB!K-s172>rp|GFU2ZY!%glR%_@)E;kZ@@a3{ zUb!l1j=h|Qm(f8)Ct@*--Bxc@gDx9XC?t~Bh~vnBd4dlZJutM~TI z64@!}=iu<`VeJW>-X0n>^voI~d%T?Le>2?gKL+?UBI%#x5C?SO8%=qv9Z{5$iJy+-F$we3!nPZMQ<{0DqzPJ1< zZENE_^OR;6e=<4N{<(po^W*cQJf7MLvV@lO$TpYR6QJHC^pkTt3Se)Mm41x&e7dAY zgG;!Ofso2xZ=~LCdjMlDS+Eipk7;)uIa+B2U>1SYDv*wiqTbw!xqX(IGubbLoY)uH z?DPt|DH6gZ)CR{s{wrZ?>43_@7jWl&U`}K2dH1HFwNL?@_o>) zGgHF1ib2_ac#TCiRl%~Xd#c|aR5J?=YQf-soFm322DJy;R!90Wa*Xy~j^`W(ZmLUt zwMJpHb&7mSM!Ez41V1p91A=(&wql0P2Dpaye21FoMWXZ(M3c%g>yFE-HudCV#;dx) zguCr#qK$#=r{T=>-|K~&L@1IwgEARYB z(0GIG&EAk9cx~iLSML8jD6um)2Fr6D+!#-LOFCUIANXN*&L;sgQ>%nb_<^i&QQ4E- zG^rh)hlWNCRxy(qZH3Qok9Y{e=hFnJT!x4`NzVxTVl|}26BNJ^&0qSI-)Z#@+l$kG z=8L~oSOy(P^~O7r$|P?==m`V915Dw1C(!2A3`R3WKA=R+gsGkY2Rg$lVSIGo>oeAF z5ow0^$&?!2xc_5W+~C$K8HPQx{v*K&DIr<0Y`yv7Tb-~rLycZHXmyj7?J0Z4XIS{b zk^*EtGBl+g^gZy_4y|@sTdVHDBKULN17FAo8SFC9UTx0I*_-~ld@LbmKGoI8e&$7s z1tQZVxEvnHDLf^0LZZX2^7A&$X z*+&@~`LTZl;;UpC***aZ(DGec*5g?3qeqC;>){AoT@Y8FJ0QG2ZNjnlq(AA& zp8(~sXz@Ueq2T(_q#>;1HF9#7pc`C=2cNAukI_8BK4YAEsKdL#*GR~L{Q&-Rz4-cY zfkuOkeBQ}EzJ)UlUo3&0@|64xn80lDQm2?|p#$PEHg*O6Ho0yD;** zi`xd8nF~tmM9#zaZKz1n6G27M||K!vt^f6ov2L(A?N>R+OtJ^pR-G|Y_3d% zgjWq^nk^S9aX^C0iyw|}+xc8EsjJDwW<@rz>#h;T(!uy?pu%eBqqq5`r853W1y|3g z2&}{2cWty65m2KR49LV{e>?+B2sUP0$+yM|R{jfx@e@NZaiSSRHU#f5*@ZLO7K7T0Dp`78p>^o7dx)dOtN0_;7xDkgIwZrIeH80^ zF48AbBT~<{eAk`)!&1`P13AqpBPL@I0_f%$th0F)0z187E538poONeqY0BZ<4O4p) zQ}HddY5?s{xjzfYe@Ai@p(qbBd z{3fP#`#8Ye5xfPa}R4C$ZFe7L#gcEvkB!WjJsUc-B3}t`8kR7s)t=QavF5tw4af-^&NT6WUDTN z;Z8Aj5(m-yWVRh5B*Bl}x9X{xt4EL?f8aKdQn=UHw^@aZ8v^U_E(MT;G+Ibgm7-u& zr^Tfy#5Qo5Zpiz;^zYSojz+>*}$!}b2)RCmr!p3KP8he;KuR`6@?+h`Fncya8l)3EE@H)Ax%*3O$aUmrih zlPp@_*I$rI(~P5*wT@uqrj3<+tP`fCLc5%&Ty?8?;;{}e9%EBIi*dw0fS=G+NMiw%^L6Hf<%c z1fc6o1<9|!D1^H_eD!R*?ANPH3gLe6J#H!F3y-SETd4$Y^6VvW)Q3A8No zgTD&g64+;$HU3c+bv-t;xQPQJ(4Pd9Wc3_X&;l~YO2kd;_^^`et@3UiNqHFXx~cr4 zljVZY-kFi#?h`JA>jbXkD>93?)3(hkq!@iLb|+h;#d#gc>%jpZo?=1QC^>Au9C(32 z{lyKy94D^ZM3cW5M2v+QbU@?py{{G_J*|U@3)L(a1+TeZo%EosJiczu0GQ9*F}5Qj zyIG9G9&7!`XCgl=arAa{-+pH zQGVIyqDU8z7zYQ3tXiNH2lhZN5}aN#_)zq;lNoyhO3VeJACb%3*YJ}Pk-c!DhEp=7 zMFrulIM9xJq`%%KZ7Me3VY1eL3nzqS(vaK>{^ zCY4zPLcb}-{-#op5VZdC*OEzD91otQc;wAtB>7}&FEhE~X_}U}CeI{~Htu$GMj}k9 z<6VTbTS86ojn>MFgR>rXpx;vLFa)vknvl0a5_eR?^=+$S`aw zLg`W1&=u`}C}zWt#~SU5?Cri-_B@6Zw`|qytH71Jtwm0f=zAoWSZY0Uv0mCs%tHvb zo+P(LK1#OleQf(FE)k>l<+Ur|4}Ny^;ps)2C$Jsz^X@}ojt;|3D6)gsONqw4U;T_9 zdh#w2Bo2H|kD}aELSGJ;6#SIj+XybcTxb6V%q_te>+LhCX$e2Pq`iJ$_AtWjq6mF% zwh=?#oH!^a@eC*PbP$sRGQYc|vKa|LBkF!V!GGKNIiO|}8CDNL31HxeAqdR$+wiHQ zZP>sP9O_fhow1C1eV8&Mb*Ivf{$it9sN5@L-@ZS;nO-vd3;SJor}bB$A}RKQ7LO|~ zE2}lsU3Tlr?0b|}<{S6(zYCN^{KRYelvnp|k*beO3gS+0Jl!LBB#r&zAthufM3#s= zm1Gw5z$kH5%;uuTo&QEc|Lb~r3TtCpZcy8rga2dF@UOj3m>o;pb}v`{wPUA>7jG)yu6q76)c-Gr`r-Fz!mlIEU+{JInc)ne z0kJ$fw~E-Vz6DHH+)a+qdP5+Z84+~8sL`;;+^#nW0%S$5BX#73K z#`2x621?QIEh&eQUFyx7;mVP&LfTPqx__rugXqJGzt(D9fRb>vxdNe1=*pasF*t$I z4k?$y+Rf(H=78*6kygr^ru3>1W3R86m)pAa30F}ViqpudhVl163+i@2guT7?3UCfD zHVMJ%O?tl`yti*mk^#si9H+1;Z^eJB&)5QvYTQ4`T-HBriD>|nGR%G^WH@|^9opeO z0iNA5;<1}Z);3b6&TCLKB2~)29xzhiSA46gIbovdzT>l)q1xpT=5;$slMm zH|s@VaLO#v8SQVIdv+5$4L}5GfJG>$ZI2y6&epWm1;^JB;N1$Lj62E#LS4e?td#0M!fiKQMmUWT z@9KzRJH!(^D-{K)hT=>xi$RqqA{U?#`kK5R_s&w!*1TNB$4p|$4{g_NHoAGsmql-f zB779eD`r@bdG^U^zvFcJU)Kb=oD@XdxL)wKIDT`G{nP1|+^P*oG(&}Z+R9J1PRvH+ z93$4?M@c4cDs$~98aFg@>3)R0T$IJ+dyh^Cc%iw@(iRn3ihC(Ac2|(peH8Y*j!!Rd zrmHUChNke+O~@#otTEq-|9%)V!Pe7|XY;9vPvt zAG9B&Uv!ft`BTw|mAnocTk_`xR%POw@nrSMI5`q8QNWGE%IPlzF>qx4w)qkSAYo^B zeTsopNp)0eCfFKl1^ecewNVY9)WmaCK2S*;&DE;G&56Q@iMt#+bxPu2i4wLtZT%p8 zo&Ds!=sR042-qq?2t%kVH;->=&{)*qHfnIez0%CsepQo)>ut+q9uPAY(`t5Cs<|iF z_*7Alf;wA{CCG6P%G!W(tJJJD23fz>mB49%QYhP;M;axVG=h0OjmOQ0}@FQy=-6b&-D#AK${xr0L_lk>w-49 z);cNTa?8$q7?iymz2T-)ie$qxTRv&=8rQb>TR=A62*jgfwgz6(%Qg&X5e8!D1JK0r zYdMFr)_Afq%ry8tUIogD$#|xC0cueKOd5(6wBdxv&!($*z0ww2#c%5DWn@TLOb*!> zFafqQOM874z~xgCXS5lC;Z|Xa;ekh(Li5^9{l@`Y}1oYRwiFvW(!>_Pcp>a@Z ztal(_Kf@oqeQdoI18>mY1H?^KXZ;V~3h;r606cFs#Xc{|-AntVi2KwjT89_9c!S@r zKGh7SV%E`I{vGgM;&R0lq#(Dn8%F)=%dFh~D4;Xa$^CG_WJ|Q3)-rt=l(iZ%QXLJK zeh8s73yWIi@H2w8tEa$Yv7Y5$V(IHKD>^2F1s;|v?SLB#HU|E+d)+v5Fs77FNe^@5 z^8)`2m|7y5N}Cx){&otEMHI;s5;>N=#o z_kt;8Z{YskfCs<&->IB&`t>`)n4y-VwZ(e7XkWs7EB4A0FRKe51$H|Iy9GjWD+8o= zXK{IY8SQIUvZYtEj!bwA9<5B_Rarin82ER7v%FJ_OchyV$oPptr6C{y=}^9UFD$Ck z_DW+EP#A>L)!$LiWqel&O9>mH_`1oD;D^o-a?Z-z9>;ufQ-F^wq-ov%s-1Ht)q7gM zrp514Ax^H%*IEA(bWBB}=;3$d!THCC(~`q>>l0$5ITm5_H}A>}v@!w;5WGni(Wv#d z=oocPfsWkTcHM+dgYx64z*)imnpmm0KtX(7Tp?b<$n-XHeL!70{=5^aMxN@m(SHWX zKY)7|QG@&Hf|Y{_8hiCPVa#KE`bqcdv~}-SqceIlXw4+t)6cZ`p6LRkCrOrB)%SJM%3~XP!GI$kG zFp0K>9yxLdif_rnQ%77|=6f1ndJ+_G_)Yrxai&T!EyoGG2Uto#QQ)E#R9fBS<>p&z z6QImcMCv}YgWRn^R@43fZxUmBiF3&Z|G>Q1p4h)dNUBhCV6nA9-fsiGNg7L3i&`cg z%5)wAJYGE|R%oYVd^}F0_bcB{XLBx`IOj~CG{o=M@j70FTxxWMS@1288I7WKs$=^F zJm4FVe9)J)IX+$pYh8tAkWGO-MXf&w5?AeEykAGsIO7a` z^C@0%aWCR&fyp?%5qzAU9I(&rbV=wH>m+6Dz6owV4>xd${QnTI61Rcj=VWMBnoE>g|uIJNz;Ty_~1H zA4rnqZ>drq2mDjAwr<_G@*Ga`Mf5pHrIaVRB66u8^0JPr5gFZK4ac8>5xPUEg)58s z99GPdCIxFeRl$Pwe6_Bbf9m@bqo-W!($eWgRcLaa5f8U-#kuWJre0oW)eRki%h_m% zJFm|!UFvD=4_WeQ{>-k(H=Jo@M=p&#HkZGS74}QHV`w~(B&A!_kav4wsi1}H&)SX3 z;r*k-;_Zh9cARe{xu&j=-nGfl||Z3ipiO|>L zIDC+szvE-J(G7!`McVmAaxm}G+5K}4-_umzb*Z$*Q}knkJ1bct`W1Bo$Ft_j5j{deo?Gm&@FGl{oNZZ z-Ba^7qI*n1vt^)Vsqhf-uaMgimv*x}jgZk-gY%t|%ld%*fCXfyGOsyHx9|L-#TAn( zYL3oS9y{5<_~)j`zJPL(!_-F_Ztz9xsgT=weTF4ZOu75p0fX1S-=L&1-GjyYGv$Jn zEaonjD2q8(me}7QxQ?_>L>jyzy3&)fM?O3 zg$3N4&=)WN(B^vadxSo+edoLaohlw$2Liz9`;UNUu%l?P<=md7InVtI?a|l8txNf< zMw75Ihz~JZ$Hj(F2AJ}K2&0H^wOh3=Kt z4FMALiC13o8%tkG3vBE6w!9I*zX+HCvi+8E8igelpDa3@9z}&_`wBQ8*y&gk5QR3T z>b9M_uCuwe?^$8+oDjr}^&*oK(NT(pZM#7%4NQ8$167BRTli8exBmC@i~G0l%$H8! z%`~*mt|BfPSUo^%3Dc7_okc~NJeG;6Dw%~HZZQ8g2{f&jAG~r?JJhIPoY8)0WcZlh zd$hae))|0+X6WiiK6l)X+po)z`}>TLpN*9Bog13TH@JlNbdwWY)lOw5+%fi3fa=6t zKZG&Hk=loI}%>DDgcvfgKN>Asx<9{5Pb_%ev~}ItGwp^QnAi zRNI#GiJ^1acRT>osniQ?3cg=56!^-0bC`c&uI+r!2YGbqMB6L-!TV#k#jjEO&+Q$n zRusfLy;`T2y+$U^aqEeQY_wwTEMJD8kI~yrnox|)CVyQ)I$ICd|}QZI^_wK{;HI6!sk1}eHxHVe9) z?Pg#nJ!al`l^CkX6g(7_)s5j_2!1mVjC!EKbsM}{w)WO6>vCD|%HiTU3;B|-L<|Veg^o&zDm;iPftS601-crd zZmFK8{tYj6jP?S=o0Veeyn94WZ&GY+QGm53z%2?mwJM-`Sw6%#t#zU)GTAdZiDEqLVDvd_QoneyJNn$_8;rhz z{`_pj>J^>=+Q%NoI`JZ=um0tp;~!*<5Kf+>^(Dm~{t7$c)!Z75L*I1*K{FmO;z3SH z*H&UXb?#XiX6P8}$8??b(ug3%jw5c45D5a{?H5bUm})pV@aR z?QEvrti_mJ-B!+sWV?))V40qt7&QWg&(Z7s5?bzu_z0?Hh|9gs`bTzIqMEIPQ9|d(_a^;%K5`%%$3&SyY#b!Mvs!#UXMvUa1o0gBq(|KYt zuiD|Z59F4?l&8D(aKV4js%+7|jkDG*#n+l?Lnb7KxoEY#lqh!EQo-kn1XyDOatdwaW zX8#6CEoSq2zEnzbJ2h&bgT#r>by_}G5JR@!ZnbF5^BIUYB1pDozzyN!ly^(|pvt?N z<+yZC7|rEVtW-xnrxmwUOjMV|f~n@F-ls0_b=q~&OdN2!zUpLAr%mcds~5~ob=|&4 z1eaiWXAJMA+W>VbNmG)61O;WoxGr9GOPPW1HePQ()NZC5+A|Wh!R_NKBaiX%i{?h! z##nE(X4Qlp^7{06J7+i`1#;gWo8A)HKr-pG0;gwzM1cIu#pb{ROIMfm{VzgQiH6Vl zYZWCXOan99|9Zv$A^DJRo}aRcX7slxgZZTbf=jsj8<#{}`k&jBO;&S-r@P|CVN8SAb)p5KtKF(ON^c@vB|`!`+FY$UJi$r!Rqj*y8W6-O zJ7r^7-&-fKz0~jhmu&v{685G`LWhL_RTY-0H)NzLN*Q6CGT6@RN(bnfy!t)P@Djr( z=G&Tbm>C&S3C5d|@X@aLRqCdL!oVawV)JFmfFuWaK|#uQz7m_i3YU2*<?u|E8brj>0)kGthvzKhs6lJ=?+O zuPerH&8iXs#DXOf4CQ)_0LUl}aDlcv2t z3@DwT6&?h0rM%`JAn5hxEb5O8wB^{%lC44YerlV~`29OyyMQ3i3PVGQ%Cb6a>b(BgD#S<~2IBcHKO%Z;jgn8bHBOSvk~JHL|L;U~NtrrWN( z^3+vyc8q|tc_i+K&F6kd{xg1DCI2GvJ>Es}xS9A=t0-8!(0Sf`wCmJB`=Qp7x%Yy4 zc(M~#^2IJcdP8i{u{#@d4 zL9nT4UVLV+PttXr5yr8+xGmGMThgvE8>%Dsb!7OjLt4ydtzg>57WC)t!80#IE^q%U zl#XngoqANRP09Z=U=Tu8?4E5r(NtIrOn6(I&OJri5rR7TT)-<4iGbI5cz~@AbT~Iu z$h{%yTeJ^^N;U=k;EL)UE>(h&{<>1Nz7h2HV*Tog)2pRJqs?hUDQd47&z`pw9Q<_o zxqC5)e*^zCJiI95(rmUq^cJ}t+4vVS_M~2DMD!kh5G6cCC|%YQUzl!JVGW^bN(!i> zrkFb?S1lCVNE*n3c6a%84&?kCH-fQ&KLb75M%2i|t67QCo_*Gm8M_g7O?C1D{>+e1 zKVtggwpDm3AyvZub>QsAAMXDn_(H)KvlYsm*Cb$@-i z+Ae232Ft1g4Kver%#TzLMEW}sPHT3ngj3DGg>?i5bs4lK*?;0qH;aSK_0>3LREcyQ z>!?mm@<%>2(Wp-c#Tjm28ELI^r`F+Y-ewekiUSdsOk(Y}y39Q8_or%c6Qx7C3_6Oh zPmVWR5b%YE;Jz#&@-f`RxuR7_cZ}){*p`pLsiyje{+ZvVx`G*b+_3rdvxHukTLHFR zFO6;_K&aXD^_$h5*ksID)0Y6V(5n_#J|WjYzMuR#!IOSzm`xDmRcw16U-QO4f$Q_4 zWAq%uVCJEv$^d~JSlEW^lxcDSKV>E-Ai#>*efF7cLAzSm$QytCSaCV2YcZNQ8@+2b z;*pD7wfx!C`-$WnE+^gFx|)@K9-NPDmR_ydl;$Z;38@R(NZio<(_kmJCj6vQI9w!h zgE#3YHTD|gYNgAy*5>C2^Ry8i*;s!r(Xhw*oTddN9$HY!#1`eia%XZkrhRJOGv#vo{Ewm&_yxt!h6GO{LFd zV0`IiF-E&N;ACV*TEY;vvRtGLv`bg%!RgYJY-LU?URk_1B{XC#i&d$z_>#KSX{nH3 zLy62sJ9s|v`(QEl@;V}#Z(@B=#dq$Gr+A9l4bM9BkU1e7HwT?L&gyP>tP{eY4$DdE zWtjE^o%hU)jh`c*k4bt#dJW6+ou@h|cBYatq)8lLxL{7PFuJvhcQq>~W4L)c$}8p>)eZGeTNLLvr#fe9}pc4&}+}i;g8pc^b>7x+@=&n<>xSNv$ZK{g z8V3$>{G3ge)y;=*h(gu6gvr~rt6@gN!p_j6_|JzdF=Z1L z^GN~sdd3XDwYr9F%7-~wSx7R8UQFUFUID|JSMkH^7MC?~Nab&w&hBPR#co(YVKw1z zC7^R{NW20F%39C4Rz1%>W)@bKzq)mLj_*;swXMyravZg?*2cBzqhjdfk-xE(n7Pkk za~O9gP5*lqg-t={3J+|hKiYjkS?VqbZbSb=eIA-jIoZotuI6qnq@3Q0o*MPdVO8Zyhc z#RWO3?b<65$|PKmfu7`9h<;Ofy>^XMN{=O75YP!3vOsjScZOX`9|~2*_y&(9*=i1Y z2K@ta8#eoTPrg0S;FhS%=yT{W_(Gxrs%HrMCFJz#^~8-&X9r+8)E|W^Nr6RBFZ#&M z&0ALlsb>%i+{4ET2KiS^XRfj8qS(>VAG&#$qo_wzIZgV`_#coOOyW)O(DlIEoGsLF zQG3-x96OLVH*mDN!2gmr*9ZOerK>3Ac}C9dD*Js`1C{PZ3;5~a)Gy4YHW2S+C71_p z2t=J__PI}=jtRHF`65rOVDybKM8*kr9GSW`f4eoZ;79aPtxiA2XDp+}uRQl`vKFjoM|5vP+eQz628*GcR0>Pt zgS^w1$rwaa^6D>%8rby0Q>>1q$YEu!QJkya9N3D#(26R)ukYODVy>ns*?PBCLTAp{ z%I)#ag*N>ZpVm9ARo-U})RRQ9b-PlvkF`Ty653=$aB*2pcyqLIAOrB&DSm1e)L{`Q zw|S$mC@YN5qQtL1Tj_ZLJgpVs@h&b%{8qH>e%GoZas;wZ!r&+C%=y5+<&deyW1?9Z_36`Df^Ct`hHsO{ zDi==cMkKs8iY=u*y3Bn7WO;)O-#7`*ybA%Z54vT84hP71-%yrY_xRIQPGgbes$1l@ z{@9lrnL>z=r;o&>=Ukb9O+MuvekB({#>eJ5YR$_WjY$`Sw7^&e>W41 z;m%63cxg~EH@HBv%;S1~d0i0X1mDxWdsLba*~no5`*Vmw%%Kw zVlLzGYvITbuB}Z=Xx&I_#vx4EYF7;7qaf3fS(1b(y;|pX>ft1>7%sv&1dyy$}<{N=W_^^K_^hcWv>AzgPqPaI6PGpS%yx82ejvcg_A{x%$^kB zpdk$Nkd0G4H4?isswsA~(gM4FnaUJvdVGXD!dRKGb!zelU*q^U-FZ_H9^cW7!o{F= zSS#p7@1t~3TW-&tk>ccV%>JsYTSezW9(Ekah_c=&I4iMR9GU{(aD8l@r!_y5^pyCC z+h;)(srdzYucw%GH#Svx(~U8>UE5GXLjuq<<0W|y`t%fQZwgf*V7!?>hG~xN2a~&$`;2icHkjt2j_3O645!X;pvOEvA!^~+Qw59!bd3MYN*m) zTbRyr8DOro@_rE)&%C}`VHVmift+LA@`M#s8wSXmX zSEm_YH#fI?5U8EFPaWG?1#%q}?-Ns|qg&T!`8vU8!-ceY!@I1l+F&bDOQ!HbK%(p$ z8j#Ei6r8l#(<-Q%4vT1Ua3%POWiAt5P|;SiGj0i}bxS{5&sB)@#{G6XKuF9+nIpZT zO6{wS(v9j2q!vD5R;kZ04?Di^1uZB9(zvHl6ODH=II=W-}S z*S}B+pO8&l&2e@rlCT@;{4Z1o?mCATBd*7j4lDc(t@HXoIUDk$)cEP+Z}<R_YIsusg5U6_c@n-#R&Hw52e@2=;A^tNx{_`UK#JB!`yn3*>H)k_0 Vbblqd>;wKxFPmQ~xM=_Q{{X?u>@WZT diff --git a/docs/screenshots/cliproxyapi-redirect-final-effect-proof.png b/docs/screenshots/cliproxyapi-redirect-final-effect-proof.png deleted file mode 100644 index 5d10f7fa4987c6426cc9c3aae89c6d5150118125..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 173639 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 z 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 From b7bced941f3aa8af692b1b4823758751337103e8 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Fri, 28 Aug 2026 08:10:09 -0700 Subject: [PATCH 171/183] Move proxy fingerprinting off main actor --- .../UsageStore+SpendDashboardTokenCost.swift | 12 +++-- Sources/CodexBar/UsageStore+TokenCost.swift | 39 +++++++++++--- Sources/CodexBar/UsageStore.swift | 6 +-- .../CLIProxyAPIPublicationGuardTests.swift | 38 ++++++++------ .../CLIProxyAPIUsageStoreTests.swift | 7 +-- .../ProviderArchitectureGatekeeperTests.swift | 52 +++++++++---------- .../UsageStoreCoverageTests.swift | 9 ++-- 7 files changed, 97 insertions(+), 66 deletions(-) diff --git a/Sources/CodexBar/UsageStore+SpendDashboardTokenCost.swift b/Sources/CodexBar/UsageStore+SpendDashboardTokenCost.swift index 439be54df3..09ec221e66 100644 --- a/Sources/CodexBar/UsageStore+SpendDashboardTokenCost.swift +++ b/Sources/CodexBar/UsageStore+SpendDashboardTokenCost.swift @@ -103,7 +103,6 @@ extension UsageStore { let costScopeSignature = self.spendDashboardTokenSnapshotScopeSignature(for: provider) let publicationRevision = self.providerPublicationRevision(for: provider) let providerConfigRevision = self.settings.providerConfigRevision(for: provider) - let cliProxyAPIAttributionGuard = self.cliProxyAPIAttributionPublicationGuard() let trigger = self.spendDashboardTokenRefreshTrigger(for: provider) self.lastSpendDashboardTokenFetchAt[provider.instanceID] = now self.lastSpendDashboardTokenFetchScope[provider.instanceID] = costScopeSignature @@ -122,6 +121,7 @@ extension UsageStore { return } + let cliProxyAPIAttributionGuard = await self.captureCLIProxyAPIAttributionPublicationGuard() do { let snapshot = try await self.loadTokenUsageSnapshot( provider: provider, @@ -137,7 +137,7 @@ extension UsageStore { initialSignature: costScopeSignature, snapshot: snapshot, includeSettingsRevision: false) - guard self.spendDashboardTokenRefreshPublicationIsCurrent( + guard await self.spendDashboardTokenRefreshPublicationIsCurrent( provider: provider, publicationRevision: publicationRevision, providerConfigRevision: providerConfigRevision, @@ -168,7 +168,7 @@ extension UsageStore { } self.publishSpendDashboardTokenSnapshot(snapshot, for: provider) } catch { - guard self.spendDashboardTokenRefreshPublicationIsCurrent( + guard await self.spendDashboardTokenRefreshPublicationIsCurrent( provider: provider, publicationRevision: publicationRevision, providerConfigRevision: providerConfigRevision, @@ -239,11 +239,13 @@ extension UsageStore { providerConfigRevision: UInt64, cliProxyAPIAttributionGuard: CLIProxyAPIAttributionPublicationGuard, costScopeSignature: String, - fetchedCredentialScopeFingerprint: String? = nil) -> Bool + fetchedCredentialScopeFingerprint: String? = nil) async -> Bool { guard self.providerPublicationRevisionIsCurrent(publicationRevision, for: provider), self.settings.providerConfigRevision(for: provider) == providerConfigRevision, - self.cliProxyAPIAttributionPublicationIsCurrent(cliProxyAPIAttributionGuard, for: provider), + await self.cliProxyAPIAttributionPublicationIsCurrentOffMain( + cliProxyAPIAttributionGuard, + for: provider), self.settings.costUsageEnabled, self.isEnabled(provider) else { diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 833cb34799..804c8cf349 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -159,10 +159,10 @@ extension UsageStore { } } - func tokenRefreshPublicationGuard(for provider: UsageProvider) -> TokenRefreshPublicationGuard { + func tokenRefreshPublicationGuard(for provider: UsageProvider) async -> TokenRefreshPublicationGuard { // Provider-specific by design: only Claude and Codex snapshots can contain CLIProxyAPI attribution. let cliProxyAPIAttribution: CLIProxyAPIAttributionPublicationGuard? = switch provider { - case .claude, .codex: self.cliProxyAPIAttributionPublicationGuard() + case .claude, .codex: await self.captureCLIProxyAPIAttributionPublicationGuard() default: nil } return TokenRefreshPublicationGuard( @@ -180,6 +180,25 @@ extension UsageStore { isIsolated: self.costUsageFetcher.cliProxyAPIAttributionIsIsolated()) } + func captureCLIProxyAPIAttributionPublicationGuard() async -> CLIProxyAPIAttributionPublicationGuard { + let fetcher = self.costUsageFetcher + return await Task.detached(priority: .utility) { + CLIProxyAPIAttributionPublicationGuard( + configurationGeneration: fetcher.cliProxyAPIConfigurationGeneration(), + telemetryRevision: fetcher.cliProxyAPIUsageTelemetryRevision(), + inputArtifactFingerprint: fetcher.cliProxyAPIInputArtifactFingerprint(), + isIsolated: fetcher.cliProxyAPIAttributionIsIsolated()) + }.value + } + + func cliProxyAPIAttributionPublicationIsCurrentOffMain( + _ guardValue: CLIProxyAPIAttributionPublicationGuard, + for provider: UsageProvider) async -> Bool + { + guard provider == .codex || provider == .claude else { return true } + return await self.captureCLIProxyAPIAttributionPublicationGuard() == guardValue + } + func cliProxyAPIAttributionPublicationIsCurrent( _ guardValue: CLIProxyAPIAttributionPublicationGuard, for provider: UsageProvider) -> Bool @@ -329,9 +348,9 @@ extension UsageStore { let costUsageSettingsRevision = self.settings.costUsageSettingsRevision let tokenSnapshotScopeSignature = self.tokenSnapshotScopeSignature(for: .codex) let tokenSnapshotPublicationRevision = self.tokenSnapshotPublicationRevision(for: .codex) - let cliProxyAPIAttributionGuard = self.cliProxyAPIAttributionPublicationGuard() return Task { @MainActor [weak self] in guard let self else { return } + let cliProxyAPIAttributionGuard = await self.captureCLIProxyAPIAttributionPublicationGuard() guard self.tokenSnapshotPublicationForCurrentProviderConfig(for: .codex) == nil else { return } let result: ( snapshot: CostUsageTokenSnapshot, @@ -365,7 +384,9 @@ extension UsageStore { self.settings.costUsageHistoryDays == historyDays, self.tokenSnapshotScopeSignature(for: .codex) == tokenSnapshotScopeSignature, self.tokenSnapshotPublicationRevision(for: .codex) == tokenSnapshotPublicationRevision, - self.cliProxyAPIAttributionPublicationIsCurrent(cliProxyAPIAttributionGuard, for: .codex), + await self.cliProxyAPIAttributionPublicationIsCurrentOffMain( + cliProxyAPIAttributionGuard, + for: .codex), self.tokenSnapshotPublicationForCurrentProviderConfig(for: .codex) == nil else { return @@ -513,11 +534,13 @@ extension UsageStore { publicationGuard: TokenRefreshPublicationGuard, historyDays: Int, costScopeSignature: String, - fetchedCredentialScopeFingerprint: String? = nil) -> Bool + fetchedCredentialScopeFingerprint: String? = nil) async -> Bool { - let cliProxyAPIAttributionIsCurrent = publicationGuard.cliProxyAPIAttribution.map { - self.cliProxyAPIAttributionPublicationIsCurrent($0, for: provider) - } ?? true + let cliProxyAPIAttributionIsCurrent = if let cliProxyAPIAttribution = publicationGuard.cliProxyAPIAttribution { + await self.cliProxyAPIAttributionPublicationIsCurrentOffMain(cliProxyAPIAttribution, for: provider) + } else { + true + } guard self.providerPublicationRevisionIsCurrent(publicationGuard.provider, for: provider), self.tokenSnapshotPublicationRevision(for: provider) == publicationGuard.tokenSnapshot, self.settings.providerConfigRevision(for: provider) == publicationGuard.providerConfig, diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 219dccbf8a..f5e0b8e299 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1503,7 +1503,7 @@ extension UsageStore { } let costScope = self.tokenCostScope(for: provider) let costScopeSignature = self.tokenSnapshotScopeSignature(for: provider) - let publicationGuard = self.tokenRefreshPublicationGuard(for: provider) + let publicationGuard = await self.tokenRefreshPublicationGuard(for: provider) if !force, self.tokenRefreshCanReuseCurrentSnapshot( provider: provider, now: now, @@ -1545,7 +1545,7 @@ extension UsageStore { historyDays: historyDays, initialSignature: costScopeSignature, snapshot: snapshot) - guard self.tokenRefreshPublicationIsCurrent( + guard await self.tokenRefreshPublicationIsCurrent( provider: provider, publicationGuard: publicationGuard, historyDays: historyDays, @@ -1578,7 +1578,7 @@ extension UsageStore { self.tokenFailureGates[provider.instanceID]?.recordSuccess() self.persistWidgetSnapshot(reason: "token-usage") } catch { - guard self.tokenRefreshPublicationIsCurrent( + guard await self.tokenRefreshPublicationIsCurrent( provider: provider, publicationGuard: publicationGuard, historyDays: historyDays, diff --git a/Tests/CodexBarTests/CLIProxyAPIPublicationGuardTests.swift b/Tests/CodexBarTests/CLIProxyAPIPublicationGuardTests.swift index 9f4dc4fef7..63af620c18 100644 --- a/Tests/CodexBarTests/CLIProxyAPIPublicationGuardTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIPublicationGuardTests.swift @@ -6,7 +6,7 @@ import Testing @MainActor struct CLIProxyAPIPublicationGuardTests { @Test - func `live refresh guard rejects cross process proxy state changes`() throws { + func `live refresh guard rejects cross process proxy state changes`() async throws { let settings = testSettingsStore(suiteName: "CLIProxyAPIPublicationGuardTests-\(UUID().uuidString)") settings.costUsageEnabled = true let metadata = try #require(ProviderRegistry.shared.metadata[.codex]) @@ -28,12 +28,12 @@ struct CLIProxyAPIPublicationGuardTests { let historyDays = settings.costUsageHistoryDays let scopeSignature = store.tokenSnapshotScopeSignature(for: .codex) - #expect(store.tokenRefreshPublicationGuard(for: .claude).cliProxyAPIAttribution != nil) - #expect(store.tokenRefreshPublicationGuard(for: .codex).cliProxyAPIAttribution != nil) - #expect(store.tokenRefreshPublicationGuard(for: .cursor).cliProxyAPIAttribution == nil) - #expect(store.tokenRefreshPublicationGuard(for: .gemini).cliProxyAPIAttribution == nil) + #expect(await store.tokenRefreshPublicationGuard(for: .claude).cliProxyAPIAttribution != nil) + #expect(await store.tokenRefreshPublicationGuard(for: .codex).cliProxyAPIAttribution != nil) + #expect(await store.tokenRefreshPublicationGuard(for: .cursor).cliProxyAPIAttribution == nil) + #expect(await store.tokenRefreshPublicationGuard(for: .gemini).cliProxyAPIAttribution == nil) - let generationGuard = store.tokenRefreshPublicationGuard(for: .codex) + let generationGuard = await store.tokenRefreshPublicationGuard(for: .codex) let artifactDirectory = root.appendingPathComponent("cost-usage", isDirectory: true) try FileManager.default.createDirectory(at: artifactDirectory, withIntermediateDirectories: true) try Data("cache".utf8).write(to: artifactDirectory.appendingPathComponent("codex-v11.json")) @@ -41,13 +41,14 @@ struct CLIProxyAPIPublicationGuardTests { in: [artifactDirectory], stateRoot: root) #expect(clearResult.errorDescription == nil) - #expect(!store.tokenRefreshPublicationIsCurrent( + let generationIsCurrent = await store.tokenRefreshPublicationIsCurrent( provider: .codex, publicationGuard: generationGuard, historyDays: historyDays, - costScopeSignature: scopeSignature)) + costScopeSignature: scopeSignature) + #expect(!generationIsCurrent) - let telemetryGuard = store.tokenRefreshPublicationGuard(for: .codex) + let telemetryGuard = await store.tokenRefreshPublicationGuard(for: .codex) let now = Date(timeIntervalSince1970: 1_775_000_000) #expect(CLIProxyAPIUsageCacheIO.merge( [ @@ -63,28 +64,31 @@ struct CLIProxyAPIPublicationGuardTests { ], cacheRoot: root, now: now) == 1) - #expect(!store.tokenRefreshPublicationIsCurrent( + let telemetryIsCurrent = await store.tokenRefreshPublicationIsCurrent( provider: .codex, publicationGuard: telemetryGuard, historyDays: historyDays, - costScopeSignature: scopeSignature)) + costScopeSignature: scopeSignature) + #expect(!telemetryIsCurrent) - let artifactGuard = store.tokenRefreshPublicationGuard(for: .codex) + let artifactGuard = await store.tokenRefreshPublicationGuard(for: .codex) let logDirectory = proxyHome.appendingPathComponent("logs", isDirectory: true) try FileManager.default.createDirectory(at: logDirectory, withIntermediateDirectories: true) try Data("request".utf8).write(to: logDirectory.appendingPathComponent("request.log")) - #expect(!store.tokenRefreshPublicationIsCurrent( + let artifactIsCurrent = await store.tokenRefreshPublicationIsCurrent( provider: .codex, publicationGuard: artifactGuard, historyDays: historyDays, - costScopeSignature: scopeSignature)) + costScopeSignature: scopeSignature) + #expect(!artifactIsCurrent) - let isolationGuard = store.tokenRefreshPublicationGuard(for: .codex) + let isolationGuard = await store.tokenRefreshPublicationGuard(for: .codex) #expect(CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected(true, stateRoot: root)) - #expect(!store.tokenRefreshPublicationIsCurrent( + let isolationIsCurrent = await store.tokenRefreshPublicationIsCurrent( provider: .codex, publicationGuard: isolationGuard, historyDays: historyDays, - costScopeSignature: scopeSignature)) + costScopeSignature: scopeSignature) + #expect(!isolationIsCurrent) } } diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift index 6f6c42feaa..907e236bc1 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift @@ -110,7 +110,7 @@ struct CLIProxyAPIUsageStoreTests { store.publishTokenSnapshot(Self.tokenSnapshot(), for: .claude) let codexPublicationRevision = store.tokenSnapshotPublicationRevision(for: .codex) let claudePublicationRevision = store.tokenSnapshotPublicationRevision(for: .claude) - let claudePublicationGuard = store.tokenRefreshPublicationGuard(for: .claude) + let claudePublicationGuard = await store.tokenRefreshPublicationGuard(for: .claude) let claudeScopeSignature = store.tokenSnapshotScopeSignature(for: .claude) var refreshes: [(UsageProvider, Bool)] = [] @@ -131,11 +131,12 @@ struct CLIProxyAPIUsageStoreTests { #expect(store.tokenSnapshotPublicationRevision(for: .codex) == codexPublicationRevision + 1) #expect(store.tokenSnapshot(for: .claude) == nil) #expect(store.tokenSnapshotPublicationRevision(for: .claude) == claudePublicationRevision + 1) - #expect(!store.tokenRefreshPublicationIsCurrent( + let claudePublicationIsCurrent = await store.tokenRefreshPublicationIsCurrent( provider: .claude, publicationGuard: claudePublicationGuard, historyDays: settings.costUsageHistoryDays, - costScopeSignature: claudeScopeSignature)) + costScopeSignature: claudeScopeSignature) + #expect(!claudePublicationIsCurrent) #expect(refreshes.map(\.0) == [.claude, .codex]) #expect(refreshes.map(\.1) == [true, true]) #expect(await recorder.wasCancelled) diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 7b49feaf79..2ac45ac6fe 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1210,61 +1210,61 @@ struct ProviderArchitectureGatekeeperTests { "double-counting the same logs."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 325, + line: 344, anchor: "let scope = self.tokenCostScope(for: .codex)", expectedProviderIDs: ["codex"], reason: "The Codex-only cache hydration path passes its fixed provider identity to shared state helpers."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 327, + line: 346, anchor: "let publicationRevision = self.providerPublicationRevision(for: .codex)", expectedProviderIDs: ["codex"], reason: "The Codex-only cache hydration path passes its fixed provider identity to shared state helpers."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 328, + line: 347, anchor: "let providerConfigRevision = self.settings.providerConfigRevision(for: .codex)", expectedProviderIDs: ["codex"], reason: "The Codex-only cache hydration path passes its fixed provider identity to shared state helpers."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 330, + line: 349, anchor: "let tokenSnapshotScopeSignature = self.tokenSnapshotScopeSignature(for: .codex)", expectedProviderIDs: ["codex"], reason: "The Codex-only cache hydration path passes its fixed provider identity to shared state helpers."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 331, + line: 350, anchor: "let tokenSnapshotPublicationRevision = self.tokenSnapshotPublicationRevision(for: .codex)", expectedProviderIDs: ["codex"], reason: "The Codex-only cache hydration path passes its fixed provider identity to shared state helpers."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 362, + line: 381, anchor: "self.settings.isCostUsageEffectivelyEnabled(for: .codex),", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 363, + line: 382, anchor: "self.isEnabled(.codex),", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 373, + line: 394, anchor: "self.installCachedTokenSnapshot(result.snapshot, for: .codex)", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 473, + line: 494, anchor: "let credentialFingerprint = CookieHeaderCache.loadForDisplay(provider: .cursor)", expectedProviderIDs: ["cursor"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 488, + line: 509, anchor: "let scope = self.tokenCostScope(for: .cursor)", expectedProviderIDs: ["cursor"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), @@ -3143,15 +3143,15 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 187, + line: 198, anchor: "guard provider == .codex || provider == .claude else { return true }", expectedProviderIDs: ["claude", "codex"], - expectedReferenceCount: 3, - expectedReferenceFingerprint: ["claude@0", "codex@0", "codex@11"], + expectedReferenceCount: 5, + expectedReferenceFingerprint: ["claude@0", "codex@0", "claude@8", "codex@8", "codex@19"], reason: "CLIProxyAPI attribution guards apply only to providers that can contain proxy-routed usage."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 335, + line: 354, anchor: "guard self.tokenSnapshotPublicationForCurrentProviderConfig(for: .codex) == nil else { return }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3159,7 +3159,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 359, + line: 378, anchor: "guard self.providerPublicationRevisionIsCurrent(publicationRevision, for: .codex),", expectedProviderIDs: ["codex"], expectedReferenceCount: 10, @@ -3169,16 +3169,16 @@ struct ProviderArchitectureGatekeeperTests { "codex@5", "codex@7", "codex@8", - "codex@9", - "codex@10", - "codex@15", - "codex@24", - "codex@25", + "codex@11", + "codex@12", + "codex@17", + "codex@26", + "codex@27", ], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 397, + line: 418, anchor: "return provider == .codex && self.codexCostCatchUpActivity?.phase == .indexing", expectedProviderIDs: ["claude", "codex", "vertexai"], expectedReferenceCount: 4, @@ -3186,7 +3186,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 462, + line: 483, anchor: "guard provider == .cursor else {", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3194,7 +3194,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 532, + line: 555, anchor: "if provider == .cursor,", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3202,7 +3202,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 553, + line: 576, anchor: "guard provider == .cursor,", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3210,7 +3210,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 575, + line: 598, anchor: "case .openai:", expectedProviderIDs: ["grok", "mistral", "openai", "opencodego", "openrouter", "xai"], expectedReferenceCount: 12, @@ -3231,7 +3231,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 657, + line: 680, anchor: "self.tokenFailureGates[.codex]?.reset()", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 2, diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index 797b52dce4..a65a9d6ded 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -133,7 +133,7 @@ struct UsageStoreCoverageTests { } @Test - func `cursor auto credential resolution cannot relax a changed history window`() throws { + func `cursor auto credential resolution cannot relax a changed history window`() async throws { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-cursor-history-race") settings.costUsageEnabled = true settings.costUsageHistoryDays = 30 @@ -163,15 +163,16 @@ struct UsageStoreCoverageTests { historyDays: 30, source: .auto, credentialFingerprint: "unresolved") - let publicationGuard = store.tokenRefreshPublicationGuard(for: .cursor) + let publicationGuard = await store.tokenRefreshPublicationGuard(for: .cursor) settings.costUsageHistoryDays = 7 - #expect(!store.tokenRefreshPublicationIsCurrent( + let publicationIsCurrent = await store.tokenRefreshPublicationIsCurrent( provider: .cursor, publicationGuard: publicationGuard, historyDays: 30, costScopeSignature: initialSignature, - fetchedCredentialScopeFingerprint: fingerprint)) + fetchedCredentialScopeFingerprint: fingerprint) + #expect(!publicationIsCurrent) } @Test From 09421d6561ae7134b322f05806ab181a1a19575c Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Fri, 28 Aug 2026 09:01:27 -0700 Subject: [PATCH 172/183] Fix proxy catch-up review findings --- .../UsageStore+CodexCostCatchUp.swift | 48 ++++++++++++------- ...Store+SpendDashboardCodexCostCatchUp.swift | 42 ++++++++++------ Sources/CodexBarCore/CostUsageModels.swift | 5 +- .../CostUsageDailyReportMergeTests.swift | 14 ++++-- .../ProviderArchitectureGatekeeperTests.swift | 48 ++++++++++++------- 5 files changed, 101 insertions(+), 56 deletions(-) diff --git a/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift b/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift index dabd71f143..a18e8f9eba 100644 --- a/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift +++ b/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift @@ -40,14 +40,10 @@ extension UsageStore { self.cancelCodexCostCatchUp() let token = UUID() - let context = CodexCostCatchUpContext( - token: token, - codexHomePath: scope.codexHomePath, - historyDays: self.settings.costUsageHistoryDays, - scopeSignature: scopeSignature, - providerConfigRevision: self.settings.providerConfigRevision(for: .codex), - costUsageSettingsRevision: self.settings.costUsageSettingsRevision, - cliProxyAPIAttributionGuard: self.cliProxyAPIAttributionPublicationGuard()) + let codexHomePath = scope.codexHomePath + let historyDays = self.settings.costUsageHistoryDays + let providerConfigRevision = self.settings.providerConfigRevision(for: .codex) + let costUsageSettingsRevision = self.settings.costUsageSettingsRevision self.codexCostCatchUpToken = token self.codexCostCatchUpScopeSignature = scopeSignature self.codexCostCatchUpMode = mode @@ -56,6 +52,14 @@ extension UsageStore { let priority: TaskPriority = mode == .accelerated ? .utility : .background self.codexCostCatchUpTask = Task(priority: priority) { @MainActor [weak self] in guard let self else { return } + let context = await CodexCostCatchUpContext( + token: token, + codexHomePath: codexHomePath, + historyDays: historyDays, + scopeSignature: scopeSignature, + providerConfigRevision: providerConfigRevision, + costUsageSettingsRevision: costUsageSettingsRevision, + cliProxyAPIAttributionGuard: self.captureCLIProxyAPIAttributionPublicationGuard()) defer { if self.codexCostCatchUpToken == token { self.codexCostCatchUpTask = nil @@ -113,7 +117,7 @@ extension UsageStore { } private func runCodexCostCatchUp(context: CodexCostCatchUpContext) async { - while self.codexCostCatchUpContextIsCurrent(context) { + while await self.codexCostCatchUpContextIsCurrent(context) { var status = await self.loadCodexCostCatchUpStatus(codexHomePath: context.codexHomePath) self.publishCodexCostCatchUpActivity( status: status, @@ -124,7 +128,7 @@ extension UsageStore { (nil, [status.progressKey]) while status.pending { do { - guard self.codexCostCatchUpContextIsCurrent(context) else { return } + guard await self.codexCostCatchUpContextIsCurrent(context) else { return } if self.codexCostCatchUpStopRequested { self.publishCodexCostCatchUpActivity( status: status, @@ -155,7 +159,7 @@ extension UsageStore { } try Task.checkCancellation() - guard self.codexCostCatchUpContextIsCurrent(context) else { return } + guard await self.codexCostCatchUpContextIsCurrent(context) else { return } if self.codexCostCatchUpStopRequested { self.publishCodexCostCatchUpActivity( status: status, @@ -185,7 +189,7 @@ extension UsageStore { Double(durationComponents.seconds) + Double(durationComponents.attoseconds) / 1_000_000_000_000_000_000) didAdvance = true - guard self.codexCostCatchUpContextIsCurrent(context) else { return } + guard await self.codexCostCatchUpContextIsCurrent(context) else { return } self.publishCodexCostCatchUpActivity( status: nextStatus, context: context, @@ -223,7 +227,7 @@ extension UsageStore { } } - guard self.codexCostCatchUpContextIsCurrent(context) else { return } + guard await self.codexCostCatchUpContextIsCurrent(context) else { return } guard didAdvance else { self.publishCodexCostCatchUpActivity( status: status, @@ -260,7 +264,7 @@ extension UsageStore { codexHomePath: context.codexHomePath, historyDays: context.historyDays) try Task.checkCancellation() - guard self.codexCostCatchUpContextIsCurrent(context) else { + guard await self.codexCostCatchUpContextIsCurrent(context) else { throw CancellationError() } @@ -284,14 +288,22 @@ extension UsageStore { return status } - private func codexCostCatchUpContextIsCurrent(_ context: CodexCostCatchUpContext) -> Bool { + private func codexCostCatchUpContextIsCurrent(_ context: CodexCostCatchUpContext) async -> Bool { + guard self.codexCostCatchUpContextStateIsCurrent(context), + await self.cliProxyAPIAttributionPublicationIsCurrentOffMain( + context.cliProxyAPIAttributionGuard, + for: .codex) + else { + return false + } + return self.codexCostCatchUpContextStateIsCurrent(context) + } + + private func codexCostCatchUpContextStateIsCurrent(_ context: CodexCostCatchUpContext) -> Bool { !Task.isCancelled && self.codexCostCatchUpToken == context.token && self.settings.providerConfigRevision(for: .codex) == context.providerConfigRevision && self.settings.costUsageSettingsRevision == context.costUsageSettingsRevision - && self.cliProxyAPIAttributionPublicationIsCurrent( - context.cliProxyAPIAttributionGuard, - for: .codex) && self.settings.costUsageHistoryDays == context.historyDays && self.settings.isCostUsageEffectivelyEnabled(for: .codex) && self.isEnabled(.codex) diff --git a/Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift b/Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift index eb28c6c181..316a5af4a7 100644 --- a/Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift +++ b/Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift @@ -78,14 +78,8 @@ extension UsageStore { self.cancelSpendDashboardCodexCostCatchUp() let token = UUID() - let context = SpendDashboardCodexCostCatchUpContext( - token: token, - accounts: accounts, - historyDays: historyDays, - scopeSignature: scopeSignature, - providerConfigRevision: self.settings.providerConfigRevision(for: .codex), - costUsageSettingsRevision: self.settings.costUsageSettingsRevision, - cliProxyAPIAttributionGuard: self.cliProxyAPIAttributionPublicationGuard()) + let providerConfigRevision = self.settings.providerConfigRevision(for: .codex) + let costUsageSettingsRevision = self.settings.costUsageSettingsRevision self.spendDashboardCodexCostCatchUpToken = token self.spendDashboardCodexCostCatchUpScopeSignature = scopeSignature self.spendDashboardCodexCostCatchUpMode = mode @@ -94,6 +88,14 @@ extension UsageStore { let priority: TaskPriority = mode == .accelerated ? .utility : .background self.spendDashboardCodexCostCatchUpTask = Task(priority: priority) { @MainActor [weak self] in guard let self else { return } + let context = await SpendDashboardCodexCostCatchUpContext( + token: token, + accounts: accounts, + historyDays: historyDays, + scopeSignature: scopeSignature, + providerConfigRevision: providerConfigRevision, + costUsageSettingsRevision: costUsageSettingsRevision, + cliProxyAPIAttributionGuard: self.captureCLIProxyAPIAttributionPublicationGuard()) defer { if self.spendDashboardCodexCostCatchUpToken == token { self.spendDashboardCodexCostCatchUpTask = nil @@ -148,7 +150,7 @@ extension UsageStore { context: SpendDashboardCodexCostCatchUpContext) async { var statuses = await self.loadSpendDashboardCodexCostCatchUpStatuses(context.accounts) - guard self.spendDashboardCodexCostCatchUpContextIsCurrent(context) else { return } + guard await self.spendDashboardCodexCostCatchUpContextIsCurrent(context) else { return } self.publishSpendDashboardCodexCostCatchUpActivity( statuses: statuses, context: context, @@ -160,7 +162,7 @@ extension UsageStore { (Set(), statuses.mapValues { Set([$0.progressKey]) }) while Self.spendDashboardCodexCatchUpIsPending(statuses) { do { - guard self.spendDashboardCodexCostCatchUpContextIsCurrent(context) else { return } + guard await self.spendDashboardCodexCostCatchUpContextIsCurrent(context) else { return } if self.spendDashboardCodexCostCatchUpStopRequested { self.publishSpendDashboardCodexCostCatchUpActivity( statuses: statuses, @@ -207,7 +209,7 @@ extension UsageStore { } try Task.checkCancellation() - guard self.spendDashboardCodexCostCatchUpContextIsCurrent(context) else { return } + guard await self.spendDashboardCodexCostCatchUpContextIsCurrent(context) else { return } if self.spendDashboardCodexCostCatchUpStopRequested { self.publishSpendDashboardCodexCostCatchUpActivity( statuses: statuses, @@ -244,7 +246,7 @@ extension UsageStore { stalledCacheIdentities.remove(account.cacheIdentity) } - guard self.spendDashboardCodexCostCatchUpContextIsCurrent(context) else { return } + guard await self.spendDashboardCodexCostCatchUpContextIsCurrent(context) else { return } let isPending = Self.spendDashboardCodexCatchUpIsPending(statuses) self.publishSpendDashboardCodexCostCatchUpActivity( statuses: statuses, @@ -278,6 +280,19 @@ extension UsageStore { } private func spendDashboardCodexCostCatchUpContextIsCurrent( + _ context: SpendDashboardCodexCostCatchUpContext) async -> Bool + { + guard self.spendDashboardCodexCostCatchUpContextStateIsCurrent(context), + await self.cliProxyAPIAttributionPublicationIsCurrentOffMain( + context.cliProxyAPIAttributionGuard, + for: .codex) + else { + return false + } + return self.spendDashboardCodexCostCatchUpContextStateIsCurrent(context) + } + + private func spendDashboardCodexCostCatchUpContextStateIsCurrent( _ context: SpendDashboardCodexCostCatchUpContext) -> Bool { !Task.isCancelled @@ -285,9 +300,6 @@ extension UsageStore { && self.spendDashboardCodexCostCatchUpScopeSignature == context.scopeSignature && self.settings.providerConfigRevision(for: .codex) == context.providerConfigRevision && self.settings.costUsageSettingsRevision == context.costUsageSettingsRevision - && self.cliProxyAPIAttributionPublicationIsCurrent( - context.cliProxyAPIAttributionGuard, - for: .codex) && max(SpendDashboardSource.scanDays, self.settings.costUsageHistoryDays) == context.historyDays && self.settings.isCostUsageEffectivelyEnabled(for: .codex) && self.isEnabled(.codex) diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index 4a232955bc..8b64d116ab 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -959,6 +959,7 @@ extension CostUsageDailyReport { var costUSD: Double = 0 var sawCost = false var unpricedRequestCount = IntegerAccumulator() + var pricedRequestCount = IntegerAccumulator() var unmeteredRequestCount = IntegerAccumulator() var estimatedRequestCount = IntegerAccumulator() var modelsUsed: Set = [] @@ -990,6 +991,7 @@ extension CostUsageDailyReport { self.sawCost = true } self.unpricedRequestCount.add(entry.unpricedRequestCount) + self.pricedRequestCount.add(entry.pricedRequestCount) self.unmeteredRequestCount.add(entry.unmeteredRequestCount) self.estimatedRequestCount.add(entry.estimatedRequestCount) if let modelsUsed = entry.modelsUsed { @@ -1041,7 +1043,8 @@ extension CostUsageDailyReport { modelBreakdowns: modelBreakdowns, unpricedRequestCount: self.unpricedRequestCount.result, unmeteredRequestCount: self.unmeteredRequestCount.result, - estimatedRequestCount: self.estimatedRequestCount.result) + estimatedRequestCount: self.estimatedRequestCount.result, + pricedRequestCount: self.pricedRequestCount.result) } private static func combinedResult( diff --git a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift index 344905826a..8d9f0d2ba0 100644 --- a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift +++ b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift @@ -169,7 +169,8 @@ struct CostUsageDailyReportMergeTests { modelBreakdowns: nil, unpricedRequestCount: 2, unmeteredRequestCount: 1, - estimatedRequestCount: 3), + estimatedRequestCount: 3, + pricedRequestCount: 4), ], summary: nil) let proxy = CostUsageDailyReport( @@ -181,7 +182,8 @@ struct CostUsageDailyReportMergeTests { totalTokens: 60, costUSD: 0.75, modelsUsed: ["gpt-5.4"], - modelBreakdowns: nil), + modelBreakdowns: nil, + pricedRequestCount: 3), ], summary: nil) @@ -192,6 +194,7 @@ struct CostUsageDailyReportMergeTests { #expect(entry.unpricedRequestCount == 2) #expect(entry.unmeteredRequestCount == 1) #expect(entry.estimatedRequestCount == 3) + #expect(entry.pricedRequestCount == 7) #expect(merged.summary?.reasoningTokens == 12) } @@ -523,7 +526,8 @@ struct CostUsageDailyReportMergeTests { requestCount: Int.max, inputTokens: Int.max, outputTokens: Int.max), - ]), + ], + pricedRequestCount: Int.max), ], summary: nil) let second = CostUsageDailyReport( @@ -544,7 +548,8 @@ struct CostUsageDailyReportMergeTests { requestCount: 1, inputTokens: 1, outputTokens: 1), - ]), + ], + pricedRequestCount: 1), ], summary: nil) @@ -555,6 +560,7 @@ struct CostUsageDailyReportMergeTests { #expect(entry.outputTokens == nil) #expect(entry.totalTokens == nil) #expect(entry.requestCount == nil) + #expect(entry.pricedRequestCount == nil) #expect(breakdown.inputTokens == nil) #expect(breakdown.outputTokens == nil) #expect(breakdown.totalTokens == nil) diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 2ac45ac6fe..799594ce91 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1011,37 +1011,43 @@ struct ProviderArchitectureGatekeeperTests { reason: "This provider-owned integration passes its fixed identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+CodexCostCatchUp.swift", - line: 48, - anchor: "providerConfigRevision: self.settings.providerConfigRevision(for: .codex),", + line: 45, + anchor: "let providerConfigRevision = self.settings.providerConfigRevision(for: .codex)", expectedProviderIDs: ["codex"], reason: "This provider-owned integration passes its fixed identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+CodexCostCatchUp.swift", - line: 257, + line: 261, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-owned integration passes its fixed identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+CodexCostCatchUp.swift", - line: 270, + line: 274, anchor: "self.publishConfirmedEmptyTokenSnapshot(for: .codex)", expectedProviderIDs: ["codex"], reason: "This provider-owned integration passes its fixed identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+CodexCostCatchUp.swift", - line: 273, + line: 277, anchor: "self.publishTokenSnapshot(snapshot, for: .codex)", expectedProviderIDs: ["codex"], reason: "This provider-owned integration passes its fixed identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+CodexCostCatchUp.swift", - line: 296, + line: 295, + anchor: "for: .codex)", + expectedProviderIDs: ["codex"], + reason: "This provider-owned integration passes its fixed identity to an off-main publication guard."), + SuppressedProviderReference( + path: "Sources/CodexBar/UsageStore+CodexCostCatchUp.swift", + line: 308, anchor: "&& self.settings.isCostUsageEffectivelyEnabled(for: .codex)", expectedProviderIDs: ["codex"], reason: "This provider-owned integration passes its fixed identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+CodexCostCatchUp.swift", - line: 297, + line: 309, anchor: "&& self.isEnabled(.codex)", expectedProviderIDs: ["codex"], reason: "This provider-owned integration passes its fixed identity to a shared helper."), @@ -1125,19 +1131,25 @@ struct ProviderArchitectureGatekeeperTests { reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift", - line: 86, - anchor: "providerConfigRevision: self.settings.providerConfigRevision(for: .codex),", + line: 81, + anchor: "let providerConfigRevision = self.settings.providerConfigRevision(for: .codex)", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift", - line: 292, + line: 288, + anchor: "for: .codex)", + expectedProviderIDs: ["codex"], + reason: "This Codex account projection passes its fixed identity to an off-main publication guard."), + SuppressedProviderReference( + path: "Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift", + line: 304, anchor: "&& self.settings.isCostUsageEffectivelyEnabled(for: .codex)", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift", - line: 293, + line: 305, anchor: "&& self.isEnabled(.codex)", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), @@ -2649,7 +2661,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+CodexCostCatchUp.swift", - line: 267, + line: 271, anchor: "self.lastTokenFetchAt[.codex] = now", expectedProviderIDs: ["codex"], expectedReferenceCount: 6, @@ -2657,11 +2669,11 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+CodexCostCatchUp.swift", - line: 290, + line: 305, anchor: "&& self.settings.providerConfigRevision(for: .codex) == context.providerConfigRevision", expectedProviderIDs: ["codex"], - expectedReferenceCount: 4, - expectedReferenceFingerprint: ["codex@0", "codex@4", "codex@8", "codex@9"], + expectedReferenceCount: 3, + expectedReferenceFingerprint: ["codex@0", "codex@5", "codex@6"], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+HighestUsage.swift", @@ -3010,11 +3022,11 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift", - line: 286, + line: 301, anchor: "&& self.settings.providerConfigRevision(for: .codex) == context.providerConfigRevision", expectedProviderIDs: ["codex"], - expectedReferenceCount: 2, - expectedReferenceFingerprint: ["codex@0", "codex@4"], + expectedReferenceCount: 1, + expectedReferenceFingerprint: ["codex@0"], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", From 37cf61dd7e7af05163db851d28b0a6ca476832bc Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sun, 30 Aug 2026 19:43:34 -0700 Subject: [PATCH 173/183] Keep foreign models out of Claude totals --- .../CodexBar/CostHistoryChartMenuView.swift | 1 + .../PreferencesSpendDashboardPane.swift | 1 + Sources/CodexBarCore/CostUsageModels.swift | 1 + .../Generated/CodexParserHash.generated.swift | 2 +- .../CostUsagePricing+ModelProvider.swift | 15 ++++++ .../Vendored/CostUsage/CostUsagePricing.swift | 11 +++++ .../CostUsage/CostUsageScanner+Claude.swift | 15 +++++- .../CostUsageClaudeKimiAliasTests.swift | 12 ++--- ...UsageFetcherUnknownModelPricingTests.swift | 45 +++++++++++++++-- .../CodexBarTests/ModelsDevPricingTests.swift | 48 +++++++++++++++++++ .../ProviderArchitectureGatekeeperTests.swift | 4 +- 11 files changed, 139 insertions(+), 16 deletions(-) diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index 25b5e91911..4ebfaa2754 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -7,6 +7,7 @@ private func claudeCodeModelProviderText(_ provider: CostUsageAttribution.ModelP case .openAI: "OpenAI model via Claude Code" case .anthropic: "Anthropic model via Claude Code" case .google: "Google model via Claude Code" + case .other: "Other model provider via Claude Code" case .unknown: "Unknown model provider via Claude Code" } } diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index 53bf2a0dfb..4b6c3906a2 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -64,6 +64,7 @@ func spendDashboardModelSourceText( case .openAI: "OpenAI model" case .anthropic: "Anthropic model" case .google: "Google model" + case .other: "Other model provider" case .unknown: "Unknown model provider" } return "\(providerName) · \(modelProvider) via Claude Code" diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index 8b64d116ab..3615fe69cc 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -373,6 +373,7 @@ public struct CostUsageAttribution: Sendable, Codable, Equatable, Hashable { case openAI case anthropic case google + case other case unknown } diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index dcb955dd52..5c90e66ee2 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 = "688cc8a20ba4758a" + static let value = "b65bb5d031b4dce7" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ModelProvider.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ModelProvider.swift index 3621fd17fa..e49def6e1b 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ModelProvider.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ModelProvider.swift @@ -25,6 +25,21 @@ extension CostUsagePricing { return .google } + if let providerID = self.claudeModelsDevResolvedProviderID( + model: model, + catalog: modelsDevCatalog, + cacheRoot: modelsDevCacheRoot) + { + // Provider-specific by design: resolved catalog ownership maps known first-party providers + // into attribution buckets. + return switch providerID.lowercased() { + case "anthropic": .anthropic + case "openai": .openAI + case "google": .google + default: .other + } + } + if self.claudeCostUSD( model: model, inputTokens: 0, diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 637fc580da..1a4bf37722 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -1006,6 +1006,17 @@ extension CostUsagePricing { guard matchedProviderIDs.count == 1 else { return nil } return matches.first } + + static func claudeModelsDevResolvedProviderID( + model: String, + catalog: ModelsDevCatalog?, + cacheRoot: URL?) -> String? + { + self.claudeModelsDevLookup( + model: model, + catalog: catalog, + cacheRoot: cacheRoot)?.pricing.providerID + } } extension CostUsagePricing { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index fd109b1539..b45c2c0a45 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -131,13 +131,24 @@ extension CostUsageScanner { outputTokens: tokens.output, modelsDevCatalog: context.modelsDevCatalog, modelsDevCacheRoot: context.modelsDevCacheRoot) + } else if pricingProvider == .other { + CostUsagePricing.claudeCostUSD( + model: pricingModel, + inputTokens: tokens.input, + cacheReadInputTokens: tokens.cacheRead, + cacheCreationInputTokens: tokens.cacheCreate, + cacheCreationInputTokens1h: tokens.cacheCreate1h, + outputTokens: tokens.output, + pricingDate: context.pricingDate, + modelsDevCatalog: context.modelsDevCatalog, + modelsDevCacheRoot: context.modelsDevCacheRoot) } else { nil } let normalizedModel = switch modelProvider { case .openAI: CostUsagePricing.normalizeCodexModel(model) case .anthropic: CostUsagePricing.normalizeClaudeModel(model) - case .google, .unknown: + case .google, .other, .unknown: model.trimmingCharacters(in: .whitespacesAndNewlines) } return ClaudeModelResolution( @@ -1480,7 +1491,7 @@ extension CostUsageScanner { modelsDevCatalog: modelsDevCatalog, modelsDevCacheRoot: modelsDevCacheRoot) } - guard pricingProvider == .anthropic else { return nil } + guard pricingProvider == .anthropic || pricingProvider == .other else { return nil } return CostUsagePricing.claudeCostUSD( model: pricingModel, inputTokens: row.input, diff --git a/Tests/CodexBarTests/CostUsageClaudeKimiAliasTests.swift b/Tests/CodexBarTests/CostUsageClaudeKimiAliasTests.swift index 0f8e993e65..a455e7c69f 100644 --- a/Tests/CodexBarTests/CostUsageClaudeKimiAliasTests.swift +++ b/Tests/CodexBarTests/CostUsageClaudeKimiAliasTests.swift @@ -10,7 +10,7 @@ struct CostUsageClaudeKimiAliasTests { private static let aliases = ["k3[1m]", "kimi-coding/k3[1m]", "kimi-for-coding/k3[1m]"] @Test(arguments: Self.aliases) - func `documented Claude Kimi alias resolves without changing recorded identity`(model: String) async throws { + func `documented Claude Kimi alias prices without entering Claude snapshot`(model: String) async throws { let fixture = try AliasFixture(model: model) defer { fixture.environment.cleanup() } let catalog = try Self.catalog(["kimi-for-coding": ["k3": Self.rates]]) @@ -23,10 +23,7 @@ struct CostUsageClaudeKimiAliasTests { #expect(row.totalTokens == 160) #expect(try abs(#require(row.costUSD) - 0.000385) < 1e-12) let snapshot = try await fixture.snapshot() - let snapshotRow = try #require(snapshot.daily.first?.modelBreakdowns?.first) - #expect(snapshotRow.modelName == model) - #expect(snapshotRow.totalTokens == 160) - #expect(try abs(#require(snapshotRow.costUSD) - 0.000385) < 1e-12) + #expect(snapshot.daily.isEmpty) #expect(CostUsagePricing.normalizeClaudeModel(model) == model) } @@ -113,11 +110,12 @@ struct CostUsageClaudeKimiAliasTests { scannerOptions: fixture.options, modelsDevClient: ModelsDevClient(transport: transport)) - let row = try #require(snapshot.daily.first?.modelBreakdowns?.first) + #expect(snapshot.daily.isEmpty) + #expect(await transport.requestCount == 1) + let row = try #require(fixture.report().data.first?.modelBreakdowns?.first) #expect(row.modelName == model) #expect(row.totalTokens == 160) #expect(try abs(#require(row.costUSD) - 0.000385) < 1e-12) - #expect(await transport.requestCount == 1) } @Test diff --git a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift index 1ac228a8a9..64610e42d9 100644 --- a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift @@ -90,7 +90,7 @@ struct CostUsageFetcherUnknownModelPricingTests { } @Test - func `fetcher reprices a bare claude vendor model after an on demand catalog refresh`() async throws { + func `claude snapshot excludes a bare foreign provider model after catalog refresh`() async throws { let fixture = try UnknownModelPricingFixture() defer { fixture.environment.cleanup() } let assistant: [String: Any] = [ @@ -119,9 +119,46 @@ struct CostUsageFetcherUnknownModelPricingTests { modelsDevClient: ModelsDevClient(transport: CostUsageFetcherModelsDevTransport( data: fixture.refreshedCatalog))) - let breakdown = try #require(snapshot.daily.first?.modelBreakdowns?.first) - #expect(breakdown.modelName == "deepseek-v4-flash") - #expect(abs((breakdown.costUSD ?? 0) - 0.0000168) < 0.0000001) + #expect(!(snapshot.daily + .flatMap { $0.modelBreakdowns ?? [] } + .contains { $0.modelName == "deepseek-v4-flash" })) + } + + @Test + func `claude snapshot excludes a resolved foreign provider model`() async throws { + let fixture = try UnknownModelPricingFixture() + defer { fixture.environment.cleanup() } + let assistant: [String: Any] = [ + "type": "assistant", + "timestamp": fixture.environment.isoString(for: fixture.day), + "message": [ + "model": "deepseek/deepseek-v4-flash", + "usage": [ + "input_tokens": 100, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 10, + ], + ], + ] + _ = try fixture.environment.writeClaudeProjectFile( + relativePath: "project-a/foreign-provider-model.jsonl", + contents: fixture.environment.jsonl([assistant])) + var options = fixture.options + options.claudeAttributionFilter = .excludeCodexBackend + + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .claude, + now: fixture.day, + refreshPricingInBackground: false, + includePiSessions: false, + scannerOptions: options, + modelsDevClient: ModelsDevClient(transport: CostUsageFetcherModelsDevTransport( + data: fixture.refreshedCatalog))) + + #expect(!(snapshot.daily + .flatMap { $0.modelBreakdowns ?? [] } + .contains { $0.modelName == "deepseek/deepseek-v4-flash" })) } @Test diff --git a/Tests/CodexBarTests/ModelsDevPricingTests.swift b/Tests/CodexBarTests/ModelsDevPricingTests.swift index 05c6c367c6..bff793e855 100644 --- a/Tests/CodexBarTests/ModelsDevPricingTests.swift +++ b/Tests/CodexBarTests/ModelsDevPricingTests.swift @@ -73,6 +73,54 @@ struct ModelsDevPricingTests { modelsDevCatalog: catalog) == .google) } + @Test + func `classifies catalog models by their resolved provider`() throws { + let catalog = try Self.catalog(""" + { + "anthropic": { + "id": "anthropic", + "models": { + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", + "cost": { "input": 3, "output": 15 } + } + } + }, + "deepseek": { + "id": "deepseek", + "models": { + "deepseek-v4": { + "id": "deepseek-v4", + "cost": { "input": 0.14, "output": 0.28 } + } + } + }, + "minimax": { + "id": "minimax", + "models": { + "minimax-m2.5": { + "id": "minimax-m2.5", + "cost": { "input": 0.2, "output": 0.4 } + } + } + } + } + """) + + #expect(CostUsagePricing.modelProvider( + for: "anthropic/claude-sonnet-4-6", + modelsDevCatalog: catalog) == .anthropic) + #expect(CostUsagePricing.modelProvider( + for: "deepseek/deepseek-v4", + modelsDevCatalog: catalog) == .other) + #expect(CostUsagePricing.modelProvider( + for: "deepseek-v4", + modelsDevCatalog: catalog) == .other) + #expect(CostUsagePricing.modelProvider( + for: "minimax/minimax-m2.5", + modelsDevCatalog: catalog) == .other) + } + @Test func `converts models dev per million token prices to per token prices`() throws { let pricing = try #require(try Self.fixtureCatalog().pricing( diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 9d249f57be..5c9987b12f 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1727,7 +1727,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/CostHistoryChartMenuView.swift", - line: 1145, + line: 1146, anchor: "let projects = provider == .codex ? snapshot.projects : []", expectedProviderIDs: ["codex"], expectedReferenceCount: 2, @@ -2255,7 +2255,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/PreferencesSpendDashboardPane.swift", - line: 412, + line: 413, anchor: "self.configuration.providerIDs.contains(UsageProvider.codex.rawValue)", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, From 5576aeb04455764490d3e27825ffb7b12855a45a Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sun, 30 Aug 2026 20:27:52 -0700 Subject: [PATCH 174/183] Preserve ambiguous Claude model usage --- .../Generated/CodexParserHash.generated.swift | 2 +- .../CostUsagePricing+ModelProvider.swift | 14 +----- .../Vendored/CostUsage/CostUsagePricing.swift | 10 ++-- ...UsageFetcherUnknownModelPricingTests.swift | 49 +++++++++++++++++++ .../CodexBarTests/ModelsDevPricingTests.swift | 15 ++++++ .../ProviderArchitectureGatekeeperTests.swift | 8 +-- 6 files changed, 78 insertions(+), 20 deletions(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 03c572036d..6dc9110e2f 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 = "62830a3b0c0297a1" + static let value = "eced3712eb255595" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ModelProvider.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ModelProvider.swift index e49def6e1b..4e32b72683 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ModelProvider.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ModelProvider.swift @@ -6,22 +6,12 @@ extension CostUsagePricing { modelsDevCatalog: ModelsDevCatalog? = nil, modelsDevCacheRoot: URL? = nil) -> CostUsageAttribution.ModelProvider { - if self.isOpenAIModel( - model, - modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot) - { + if self.isBundledOpenAIModel(model) { return .openAI } let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.lowercased().hasPrefix("gemini-") - || modelsDevCatalog?.pricing(providerID: "google", modelID: trimmed) != nil - || ModelsDevPricingPipeline.lookup( - providerID: "google", - modelID: trimmed, - cacheRoot: modelsDevCacheRoot) != nil - { + if trimmed.lowercased().hasPrefix("gemini-") { return .google } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 1a4bf37722..4553e6c858 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -540,9 +540,7 @@ enum CostUsagePricing { modelsDevCatalog: ModelsDevCatalog? = nil, modelsDevCacheRoot: URL? = nil) -> Bool { - let normalized = self.normalizeCodexModel(model) - // Provider-specific by design: native OpenAI models first consult the bundled Codex pricing table. - if normalized != self.codexUnattributedModel, self.codex[normalized] != nil { + if self.isBundledOpenAIModel(model) { return true } return self.modelsDevLookup( @@ -552,6 +550,12 @@ enum CostUsagePricing { cacheRoot: modelsDevCacheRoot) != nil } + static func isBundledOpenAIModel(_ model: String) -> Bool { + let normalized = self.normalizeCodexModel(model) + // Provider-specific by design: native OpenAI models first consult the bundled Codex pricing table. + return normalized != self.codexUnattributedModel && self.codex[normalized] != nil + } + static func codexDisplayLabel(model: String) -> String? { let key = self.normalizeCodexModel(model) return self.codex[key]?.displayLabel diff --git a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift index 64610e42d9..1fceace79b 100644 --- a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift @@ -124,6 +124,55 @@ struct CostUsageFetcherUnknownModelPricingTests { .contains { $0.modelName == "deepseek-v4-flash" })) } + @Test + func `claude snapshot retains a bare model with ambiguous catalog ownership`() async throws { + let fixture = try UnknownModelPricingFixture() + defer { fixture.environment.cleanup() } + let assistant: [String: Any] = [ + "type": "assistant", + "timestamp": fixture.environment.isoString(for: fixture.day), + "message": [ + "model": "shared-model", + "usage": [ + "input_tokens": 100, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 10, + ], + ], + ] + _ = try fixture.environment.writeClaudeProjectFile( + relativePath: "project-a/ambiguous-provider-model.jsonl", + contents: fixture.environment.jsonl([assistant])) + var options = fixture.options + options.claudeAttributionFilter = .excludeCodexBackend + let ambiguousCatalog = Data(""" + { + "openai": { + "id": "openai", + "models": { "shared-model": { "id": "shared-model", "cost": { "input": 2, "output": 8 } } } + }, + "anthropic": { + "id": "anthropic", + "models": { "shared-model": { "id": "shared-model", "cost": { "input": 3, "output": 15 } } } + } + } + """.utf8) + + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .claude, + now: fixture.day, + refreshPricingInBackground: false, + includePiSessions: false, + scannerOptions: options, + modelsDevClient: ModelsDevClient(transport: CostUsageFetcherModelsDevTransport( + data: ambiguousCatalog))) + + #expect(snapshot.daily + .flatMap { $0.modelBreakdowns ?? [] } + .contains { $0.modelName == "shared-model" }) + } + @Test func `claude snapshot excludes a resolved foreign provider model`() async throws { let fixture = try UnknownModelPricingFixture() diff --git a/Tests/CodexBarTests/ModelsDevPricingTests.swift b/Tests/CodexBarTests/ModelsDevPricingTests.swift index bff793e855..969082e075 100644 --- a/Tests/CodexBarTests/ModelsDevPricingTests.swift +++ b/Tests/CodexBarTests/ModelsDevPricingTests.swift @@ -28,6 +28,21 @@ struct ModelsDevPricingTests { #expect(anthropic.pricing.outputCostPerToken == 4 / 1_000_000.0) } + @Test + func `ambiguous bare model ownership remains unknown`() throws { + let catalog = try Self.fixtureCatalog() + + #expect(CostUsagePricing.modelProvider( + for: "shared-model", + modelsDevCatalog: catalog) == .unknown) + #expect(CostUsagePricing.modelProvider( + for: "openai/shared-model", + modelsDevCatalog: catalog) == .openAI) + #expect(CostUsagePricing.modelProvider( + for: "anthropic/shared-model", + modelsDevCatalog: catalog) == .anthropic) + } + @Test func `does not fall back across providers`() throws { let catalog = try Self.fixtureCatalog() diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 5c9987b12f..34347b91f0 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -3779,7 +3779,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift", - line: 581, + line: 585, anchor: "if self.claude[base] != nil {", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3787,7 +3787,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift", - line: 614, + line: 618, anchor: "let bundled = lookup.pricing.providerID == self.codexModelsDevProviderID ? self.codex[key] : nil", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3795,7 +3795,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift", - line: 648, + line: 652, anchor: "guard let pricing = self.codex[key] else { return nil }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3803,7 +3803,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift", - line: 816, + line: 820, anchor: "guard let pricing = self.claude[key] else { return nil }", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, From f610e5a77fc3143c694340a8a60329a4ce853e98 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Sun, 30 Aug 2026 21:16:28 -0700 Subject: [PATCH 175/183] Harden CLIProxyAPI recovery boundaries --- .../CLIProxyAPIUsageTelemetry.swift | 38 ++++++- .../CostUsageCacheLocations.swift | 63 +++++++++-- .../CLIProxyAPIAttributionResolverTests.swift | 27 ++++- ...LIProxyAPIPreCredentialRollbackTests.swift | 102 ++++++++++++++++++ .../CLIProxyAPIUsageCacheTests.swift | 16 ++- 5 files changed, 231 insertions(+), 15 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift index e304888f58..0d83964488 100644 --- a/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -634,7 +634,7 @@ public struct CLIProxyAPIConnectionSettings: Codable, Equatable, Sendable { let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https", let host = url.host?.lowercased(), - ["127.0.0.1", "::1", "localhost"].contains(host) + ["127.0.0.1", "::1"].contains(host) else { return nil } return url } @@ -774,6 +774,32 @@ public enum CLIProxyAPIConnectionSettingsStore { } } + static func rollbackCredentialMatches(fingerprint: String?, wasMissing: Bool) -> Bool? { + switch KeychainCacheStore.load(key: self.key, as: CLIProxyAPIConnectionSettings.self) { + case let .found(settings): + guard !wasMissing, let fingerprint else { return false } + return self.credentialFingerprint(settings) == fingerprint + case .missing: + return wasMissing + case .temporarilyUnavailable, .invalid: + return nil + } + } + + private static func rollbackCredentialProvenance( + _ storedSettings: StoredSettingsSnapshot) -> (fingerprint: String?, wasMissing: Bool)? + { + switch storedSettings { + case let .found(settings): + guard let fingerprint = self.credentialFingerprint(settings) else { return nil } + return (fingerprint, false) + case .missing: + return (nil, true) + case .unavailable: + return nil + } + } + static func artifactDisposition( _ settings: CLIProxyAPIConnectionSettings, isDisconnected: Bool, @@ -808,6 +834,9 @@ public enum CLIProxyAPIConnectionSettingsStore { { let wasDisconnected = operations.isDisconnected() let storedSettings = operations.loadStored() + guard let rollbackCredentialProvenance = self.rollbackCredentialProvenance(storedSettings) else { + return false + } guard let artifactDisposition = self.artifactDisposition( settings, isDisconnected: wasDisconnected, @@ -837,6 +866,8 @@ public enum CLIProxyAPIConnectionSettingsStore { disconnectedStateAfterRollback: wasDisconnected, replacementCredentialFingerprint: replacementCredentialFingerprint, replacementCredentialsStored: false, + rollbackCredentialFingerprint: rollbackCredentialProvenance.fingerprint, + rollbackCredentialWasMissing: rollbackCredentialProvenance.wasMissing, prepareState: { operations.setDisconnectedState(true) }) else { _ = operations.setDisconnectedState(wasDisconnected) @@ -988,12 +1019,17 @@ public enum CLIProxyAPIConnectionSettingsStore { generationUpdate, fileManager: fileManager) } + guard let rollbackCredentialProvenance = self.rollbackCredentialProvenance(snapshot.storedSettings) else { + return .configurationRemovalFailed + } guard let artifactsUpdate = CostUsageCacheLocations.prepareCLIProxyAPIArtifactsUpdate( in: directories, stateRoot: stateRoot, expectedGeneration: generationUpdate.generation, fileManager: fileManager, disconnectedStateAfterRollback: snapshot.wasDisconnected, + rollbackCredentialFingerprint: rollbackCredentialProvenance.fingerprint, + rollbackCredentialWasMissing: rollbackCredentialProvenance.wasMissing, removalIsolationPublished: false, removalCredentialsCleared: false) else { return .configurationRemovalFailed } diff --git a/Sources/CodexBarCore/CostUsageCacheLocations.swift b/Sources/CodexBarCore/CostUsageCacheLocations.swift index 7a9b1b6245..be53e51cf3 100644 --- a/Sources/CodexBarCore/CostUsageCacheLocations.swift +++ b/Sources/CodexBarCore/CostUsageCacheLocations.swift @@ -12,6 +12,7 @@ public struct CostUsageCacheClearResult: Equatable, Sendable { public let errorDescription: String? } +// swiftlint:disable:next type_body_length public enum CostUsageCacheLocations { struct CLIProxyAPIArtifactsUpdate: Sendable { struct Move: Sendable { @@ -46,6 +47,8 @@ public enum CostUsageCacheLocations { let disconnectedStateAfterRollback: Bool? let replacementCredentialFingerprint: String? let replacementCredentialsStored: Bool? + let rollbackCredentialFingerprint: String? + let rollbackCredentialWasMissing: Bool? let forceRollback: Bool? let rollbackCredentialsRestored: Bool? let removalIsolationPublished: Bool? @@ -221,6 +224,8 @@ public enum CostUsageCacheLocations { disconnectedStateAfterRollback: Bool? = nil, replacementCredentialFingerprint: String? = nil, replacementCredentialsStored: Bool? = nil, + rollbackCredentialFingerprint: String? = nil, + rollbackCredentialWasMissing: Bool? = nil, removalIsolationPublished: Bool? = nil, removalCredentialsCleared: Bool? = nil, prepareState: () -> Bool = { true }) -> CLIProxyAPIArtifactsUpdate? @@ -249,6 +254,8 @@ public enum CostUsageCacheLocations { disconnectedStateAfterRollback: disconnectedStateAfterRollback, replacementCredentialFingerprint: replacementCredentialFingerprint, replacementCredentialsStored: replacementCredentialsStored, + rollbackCredentialFingerprint: rollbackCredentialFingerprint, + rollbackCredentialWasMissing: rollbackCredentialWasMissing, forceRollback: nil, rollbackCredentialsRestored: nil, removalIsolationPublished: removalIsolationPublished, @@ -336,6 +343,8 @@ public enum CostUsageCacheLocations { disconnectedStateAfterRollback: manifest.disconnectedStateAfterRollback, replacementCredentialFingerprint: manifest.replacementCredentialFingerprint, replacementCredentialsStored: manifest.replacementCredentialsStored, + rollbackCredentialFingerprint: manifest.rollbackCredentialFingerprint, + rollbackCredentialWasMissing: manifest.rollbackCredentialWasMissing, forceRollback: true, rollbackCredentialsRestored: rollbackCredentialsRestored || manifest.rollbackCredentialsRestored == true, removalIsolationPublished: manifest.removalIsolationPublished, @@ -366,6 +375,8 @@ public enum CostUsageCacheLocations { disconnectedStateAfterRollback: manifest.disconnectedStateAfterRollback, replacementCredentialFingerprint: manifest.replacementCredentialFingerprint, replacementCredentialsStored: manifest.replacementCredentialsStored, + rollbackCredentialFingerprint: manifest.rollbackCredentialFingerprint, + rollbackCredentialWasMissing: manifest.rollbackCredentialWasMissing, forceRollback: true, rollbackCredentialsRestored: true, removalIsolationPublished: manifest.removalIsolationPublished, @@ -398,6 +409,8 @@ public enum CostUsageCacheLocations { disconnectedStateAfterRollback: manifest.disconnectedStateAfterRollback, replacementCredentialFingerprint: manifest.replacementCredentialFingerprint, replacementCredentialsStored: true, + rollbackCredentialFingerprint: manifest.rollbackCredentialFingerprint, + rollbackCredentialWasMissing: manifest.rollbackCredentialWasMissing, forceRollback: manifest.forceRollback, rollbackCredentialsRestored: manifest.rollbackCredentialsRestored, removalIsolationPublished: manifest.removalIsolationPublished, @@ -430,6 +443,8 @@ public enum CostUsageCacheLocations { disconnectedStateAfterRollback: manifest.disconnectedStateAfterRollback, replacementCredentialFingerprint: manifest.replacementCredentialFingerprint, replacementCredentialsStored: manifest.replacementCredentialsStored, + rollbackCredentialFingerprint: manifest.rollbackCredentialFingerprint, + rollbackCredentialWasMissing: manifest.rollbackCredentialWasMissing, forceRollback: manifest.forceRollback, rollbackCredentialsRestored: manifest.rollbackCredentialsRestored, removalIsolationPublished: manifest.removalIsolationPublished, @@ -462,6 +477,8 @@ public enum CostUsageCacheLocations { disconnectedStateAfterRollback: manifest.disconnectedStateAfterRollback, replacementCredentialFingerprint: manifest.replacementCredentialFingerprint, replacementCredentialsStored: manifest.replacementCredentialsStored, + rollbackCredentialFingerprint: manifest.rollbackCredentialFingerprint, + rollbackCredentialWasMissing: manifest.rollbackCredentialWasMissing, forceRollback: manifest.forceRollback, rollbackCredentialsRestored: manifest.rollbackCredentialsRestored, removalIsolationPublished: true, @@ -513,11 +530,42 @@ public enum CostUsageCacheLocations { return self.removeCLIProxyAPIArtifactsManifest(update.manifestURL, fileManager: fileManager) } + @discardableResult + private static func recoverCLIProxyAPIRollbackCredentials( + manifest: CLIProxyAPIArtifactsTransactionManifest, + update: CLIProxyAPIArtifactsUpdate, + stateRoot: URL?, + fileManager: FileManager, + recoverRollbackConfiguration: ((String?, Bool) -> Bool?)?) -> Bool + { + guard manifest.forceRollback == true, manifest.rollbackCredentialsRestored != true else { return true } + guard self.setCLIProxyAPIExplicitlyDisconnected( + true, + stateRoot: stateRoot, + fileManager: fileManager), + let rollbackCredentialWasMissing = manifest.rollbackCredentialWasMissing + else { return false } + let recoverRollbackConfiguration = recoverRollbackConfiguration ?? { + CLIProxyAPIConnectionSettingsStore.rollbackCredentialMatches( + fingerprint: $0, + wasMissing: $1) + } + guard let rollbackCredentialsRestored = recoverRollbackConfiguration( + manifest.rollbackCredentialFingerprint, + rollbackCredentialWasMissing), + rollbackCredentialsRestored + else { return false } + return self.markCLIProxyAPIArtifactsRollbackCredentialsRestored( + update, + fileManager: fileManager) + } + @discardableResult static func recoverCLIProxyAPIArtifactsTransaction( stateRoot: URL?, fileManager: FileManager = .default, recoverReplacementConfiguration: ((String) -> Bool?)? = nil, + recoverRollbackConfiguration: ((String?, Bool) -> Bool?)? = nil, recoverRemovalConfiguration: (() -> Bool)? = nil) -> Bool { let manifestURL = self.cliProxyAPIArtifactsTransactionURL( @@ -534,14 +582,13 @@ public enum CostUsageCacheLocations { stagedURL: URL(fileURLWithPath: $0.stagedPath)) }, manifestURL: manifestURL) - if manifest.forceRollback == true, manifest.rollbackCredentialsRestored != true { - guard self.setCLIProxyAPIExplicitlyDisconnected( - true, - stateRoot: stateRoot, - fileManager: fileManager) - else { return false } - return self.discardCLIProxyAPIArtifactsUpdate(update, fileManager: fileManager) - } + guard self.recoverCLIProxyAPIRollbackCredentials( + manifest: manifest, + update: update, + stateRoot: stateRoot, + fileManager: fileManager, + recoverRollbackConfiguration: recoverRollbackConfiguration) + else { return false } let didCommit = manifest.forceRollback != true && self.cliProxyAPIConfigurationGeneration(stateRoot: stateRoot, fileManager: fileManager) == manifest.expectedGeneration diff --git a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift index 6ddec789c1..564d76c838 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift @@ -992,7 +992,7 @@ extension CLIProxyAPIAttributionResolverTests { #expect(CLIProxyAPIConnectionSettings( baseURL: "http://127.0.0.1:8317", managementKey: "secret").isConfigured) - #expect(CLIProxyAPIConnectionSettings( + #expect(!CLIProxyAPIConnectionSettings( baseURL: "http://localhost:8317", managementKey: "secret").isConfigured) #expect(!CLIProxyAPIConnectionSettings( @@ -1006,6 +1006,31 @@ extension CLIProxyAPIAttributionResolverTests { managementKey: "secret").isConfigured) } + @Test + func `queue client rejects localhost before constructing an authenticated request`() async { + let requests = LockIsolated(0) + let client = CLIProxyAPIUsageQueueClient( + settings: .init(baseURL: "http://localhost:8317", managementKey: "management-secret"), + dataLoader: { _ in + requests.setValue(1) + throw CLIProxyAPIUsageQueueClient.ClientError.invalidResponse + }) + + do { + _ = try await client.pop(count: 1) + Issue.record("Expected the hostname-based management URL to be rejected.") + } catch let error as CLIProxyAPIUsageQueueClient.ClientError { + guard case .invalidBaseURL = error else { + Issue.record("Expected invalidBaseURL, received \(error).") + return + } + } catch { + Issue.record("Expected invalidBaseURL, received \(error).") + } + + #expect(requests.value == 0) + } + private static let tokens = CLIProxyAPIAttributionResolver.TokenSignature( input: 10, cacheRead: 30, diff --git a/Tests/CodexBarTests/CLIProxyAPIPreCredentialRollbackTests.swift b/Tests/CodexBarTests/CLIProxyAPIPreCredentialRollbackTests.swift index d1d560884b..f6e2d27c96 100644 --- a/Tests/CodexBarTests/CLIProxyAPIPreCredentialRollbackTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIPreCredentialRollbackTests.swift @@ -3,6 +3,108 @@ import Testing @testable import CodexBarCore struct CLIProxyAPIPreCredentialRollbackTests { + @Test + func `rollback recovery verifies restored credentials before restoring staged artifacts`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-credential-rollback-recovery-\(UUID().uuidString)", isDirectory: true) + let costUsage = root.appendingPathComponent("cost-usage", isDirectory: true) + let usageFile = costUsage.appendingPathComponent(CostUsageCacheLocations.cliProxyAPIUsageFileName) + try fileManager.createDirectory(at: costUsage, withIntermediateDirectories: true) + try Data("telemetry".utf8).write(to: usageFile) + defer { try? fileManager.removeItem(at: root) } + + let generationUpdate = try #require(CostUsageCacheLocations + .prepareCLIProxyAPIConfigurationGenerationUpdate(stateRoot: root, fileManager: fileManager)) + let artifactsUpdate = try #require(CostUsageCacheLocations.prepareCLIProxyAPIArtifactsUpdate( + in: [costUsage], + stateRoot: root, + expectedGeneration: generationUpdate.generation, + fileManager: fileManager, + disconnectedStateAfterRollback: false, + rollbackCredentialFingerprint: "previous-fingerprint", + rollbackCredentialWasMissing: false, + prepareState: { + CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected( + true, + stateRoot: root, + fileManager: fileManager) + })) + #expect(CostUsageCacheLocations.markCLIProxyAPIArtifactsUpdateForRollback( + artifactsUpdate, + fileManager: fileManager)) + + let recovered = CostUsageCacheLocations.recoverCLIProxyAPIArtifactsTransaction( + stateRoot: root, + fileManager: fileManager, + recoverRollbackConfiguration: { fingerprint, wasMissing in + #expect(fingerprint == "previous-fingerprint") + #expect(!wasMissing) + return true + }) + + #expect(recovered) + #expect(fileManager.fileExists(atPath: usageFile.path)) + #expect(!CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( + stateRoot: root, + fileManager: fileManager)) + #expect(artifactsUpdate.manifestURL.map { !fileManager.fileExists(atPath: $0.path) } == true) + CostUsageCacheLocations.discardCLIProxyAPIConfigurationGenerationUpdate( + generationUpdate, + fileManager: fileManager) + } + + @Test + func `rollback recovery preserves staged artifacts while credential restoration is unverified`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-credential-rollback-unverified-\(UUID().uuidString)", isDirectory: true) + let costUsage = root.appendingPathComponent("cost-usage", isDirectory: true) + let usageFile = costUsage.appendingPathComponent(CostUsageCacheLocations.cliProxyAPIUsageFileName) + try fileManager.createDirectory(at: costUsage, withIntermediateDirectories: true) + try Data("telemetry".utf8).write(to: usageFile) + defer { try? fileManager.removeItem(at: root) } + + let generationUpdate = try #require(CostUsageCacheLocations + .prepareCLIProxyAPIConfigurationGenerationUpdate(stateRoot: root, fileManager: fileManager)) + let artifactsUpdate = try #require(CostUsageCacheLocations.prepareCLIProxyAPIArtifactsUpdate( + in: [costUsage], + stateRoot: root, + expectedGeneration: generationUpdate.generation, + fileManager: fileManager, + disconnectedStateAfterRollback: false, + rollbackCredentialWasMissing: true, + prepareState: { + CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected( + true, + stateRoot: root, + fileManager: fileManager) + })) + #expect(CostUsageCacheLocations.markCLIProxyAPIArtifactsUpdateForRollback( + artifactsUpdate, + fileManager: fileManager)) + + let recovered = CostUsageCacheLocations.recoverCLIProxyAPIArtifactsTransaction( + stateRoot: root, + fileManager: fileManager, + recoverRollbackConfiguration: { fingerprint, wasMissing in + #expect(fingerprint == nil) + #expect(wasMissing) + return false + }) + + #expect(!recovered) + #expect(!fileManager.fileExists(atPath: usageFile.path)) + #expect(artifactsUpdate.moves.allSatisfy { fileManager.fileExists(atPath: $0.stagedURL.path) }) + #expect(CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( + stateRoot: root, + fileManager: fileManager)) + #expect(artifactsUpdate.manifestURL.map { fileManager.fileExists(atPath: $0.path) } == true) + CostUsageCacheLocations.discardCLIProxyAPIConfigurationGenerationUpdate( + generationUpdate, + fileManager: fileManager) + } + @Test func `pre credential rollback restores staged artifacts without isolating the prior configuration`() throws { let fileManager = FileManager.default diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift index b7b7f552f7..f21d65699f 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -1084,7 +1084,7 @@ extension CLIProxyAPIUsageCacheTests { } @Test - func `failed credential rollback keeps replacement telemetry isolated`() throws { + func `failed credential rollback keeps staged telemetry isolated for retry`() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory .appendingPathComponent("cliproxy-credential-rollback-failure-\(UUID().uuidString)", isDirectory: true) @@ -1131,17 +1131,23 @@ extension CLIProxyAPIUsageCacheTests { #expect(try fileManager.contentsOfDirectory(at: costUsage, includingPropertiesForKeys: nil) .contains { $0.lastPathComponent.hasSuffix("replacement-backup") }) - try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( + let recovered = CostUsageCacheLocations.recoverCLIProxyAPIArtifactsTransaction( stateRoot: root, - fileManager: fileManager) {} + fileManager: fileManager, + recoverRollbackConfiguration: { fingerprint, wasMissing in + #expect(fingerprint == CLIProxyAPIConnectionSettingsStore.credentialFingerprint(existing)) + #expect(!wasMissing) + return false + }) + #expect(!recovered) #expect(storedSettings.value == replacement) #expect(disconnected.value) #expect(!fileManager.fileExists(atPath: usageFile.path)) - #expect(!fileManager.fileExists( + #expect(fileManager.fileExists( atPath: root.appendingPathComponent("cliproxyapi-artifacts-transaction-v1.json").path)) #expect(try fileManager.contentsOfDirectory(at: costUsage, includingPropertiesForKeys: nil) - .allSatisfy { !$0.lastPathComponent.hasSuffix("replacement-backup") }) + .contains { $0.lastPathComponent.hasSuffix("replacement-backup") }) } @Test From 4443b51e8d97878ad1f9bd24549dbbc61f76f445 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 31 Aug 2026 06:53:19 -0700 Subject: [PATCH 176/183] Fix proxy attribution edge cases --- .../CLIProxyAPIAttributionResolver.swift | 5 +- .../CostUsage/CostUsageScanner+Claude.swift | 3 +- .../CLIProxyAPIAliasRegressionTests.swift | 6 +- ...UsageFetcherUnknownModelPricingTests.swift | 66 +++++++++++++++++++ 4 files changed, 74 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift index aa1c500069..39690a9a30 100644 --- a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift +++ b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift @@ -661,10 +661,11 @@ struct CLIProxyAPIAttributionResolver: Sendable { let line = String(rawLine) let trimmed = line.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty, !trimmed.hasPrefix("#") else { continue } + let structure = self.simpleYAMLScalar(trimmed) let indent = line.prefix { $0 == " " }.count if rootIndent == nil { - if trimmed == "oauth-model-alias:" { + if structure == "oauth-model-alias:" { rootIndent = indent } continue @@ -675,7 +676,7 @@ struct CLIProxyAPIAttributionResolver: Sendable { break } if codexIndent == nil { - if trimmed == "codex:" { + if structure == "codex:" { codexIndent = indent } continue diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 65ab7ac99c..204a9be0d1 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -1311,7 +1311,8 @@ extension CostUsageScanner { { var result = ClaudeReportAggregation() let rowsWithProviders = Self.reconciledClaudeRows(cache: cache).map { row in - let modelProvider = if let cachedProvider = row.attribution?.modelProvider, + let modelProvider = if row.attribution?.route == .cliProxyAPI, + let cachedProvider = row.attribution?.modelProvider, cachedProvider != .unknown { cachedProvider diff --git a/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift b/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift index 17fd04a3fc..4578ce9a09 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift @@ -59,13 +59,13 @@ struct CLIProxyAPIAliasRegressionTests { } @Test - func `codex oauth alias parser ignores comments and other providers`() { + func `codex oauth alias parser ignores inline comments and other providers`() { let configuration = """ # oauth-model-alias: # codex: # - name: "ignored" - oauth-model-alias: - codex: + oauth-model-alias: # configured routes + codex: # Codex OAuth aliases - name: 'gpt-5.5' alias: 'proxy-codex-alias' # local alias vertex: diff --git a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift index 1fceace79b..f4d6565ba1 100644 --- a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift @@ -173,6 +173,60 @@ struct CostUsageFetcherUnknownModelPricingTests { .contains { $0.modelName == "shared-model" }) } + @Test + func `claude cached ownership follows catalog ambiguity changes`() throws { + let fixture = try UnknownModelPricingFixture() + defer { fixture.environment.cleanup() } + let model = "shared-cached-model" + let assistant: [String: Any] = [ + "type": "assistant", + "timestamp": fixture.environment.isoString(for: fixture.day), + "message": [ + "model": model, + "usage": [ + "input_tokens": 100, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 10, + ], + ], + ] + _ = try fixture.environment.writeClaudeProjectFile( + relativePath: "project-a/cached-ambiguous-provider-model.jsonl", + contents: fixture.environment.jsonl([assistant])) + var options = fixture.options + options.claudeAttributionFilter = .excludeCodexBackend + options.refreshMinIntervalSeconds = 0 + let openAICatalog = try Self.catalog(model: model, providerIDs: ["openai"]) + #expect(ModelsDevCache.save( + catalog: openAICatalog, + fetchedAt: fixture.day, + cacheRoot: fixture.environment.cacheRoot)) + + let foreign = CostUsageScanner.loadDailyReport( + provider: .claude, + since: fixture.day, + until: fixture.day, + now: fixture.day, + options: options) + #expect(!foreign.data.contains { $0.modelBreakdowns?.contains { $0.modelName == model } == true }) + + let ambiguousCatalog = try Self.catalog(model: model, providerIDs: ["openai", "anthropic"]) + #expect(ModelsDevCache.save( + catalog: ambiguousCatalog, + fetchedAt: fixture.day.addingTimeInterval(1), + cacheRoot: fixture.environment.cacheRoot)) + options.refreshMinIntervalSeconds = 3600 + + let ambiguous = CostUsageScanner.loadDailyReport( + provider: .claude, + since: fixture.day, + until: fixture.day, + now: fixture.day.addingTimeInterval(1), + options: options) + #expect(ambiguous.data.contains { $0.modelBreakdowns?.contains { $0.modelName == model } == true }) + } + @Test func `claude snapshot excludes a resolved foreign provider model`() async throws { let fixture = try UnknownModelPricingFixture() @@ -247,6 +301,18 @@ struct CostUsageFetcherUnknownModelPricingTests { #expect(snapshot.daily.first?.modelBreakdowns?.map(\.modelName) == ["gpt-new"]) } + private static func catalog(model: String, providerIDs: [String]) throws -> ModelsDevCatalog { + let providers = providerIDs.map { providerID in + """ + "\(providerID)": { + "id": "\(providerID)", + "models": { "\(model)": { "id": "\(model)", "cost": { "input": 2, "output": 8 } } } + } + """ + }.joined(separator: ",") + return try JSONDecoder().decode(ModelsDevCatalog.self, from: Data("{\(providers)}".utf8)) + } + @Test func `background pricing refresh returns unpriced usage before catalog download finishes`() async throws { let fixture = try UnknownModelPricingFixture() From 65f36ab8ab6a66e7e8d9686e51c01eee51c643a5 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 31 Aug 2026 08:28:12 -0700 Subject: [PATCH 177/183] Handle empty proxy upstream lists --- .../CLIProxyAPIAttributionResolver.swift | 34 ++++++++-- .../CLIProxyAPIAttributionResolverTests.swift | 62 +++++++++++++++++++ 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift index 39690a9a30..76713699e3 100644 --- a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift +++ b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift @@ -757,12 +757,7 @@ struct CLIProxyAPIAttributionResolver: Sendable { let text = try? String(contentsOf: url, encoding: .utf8) else { return false } let conflictingKeys = ["codex-api-key", "openai-compatibility"] - return text.split(whereSeparator: \.isNewline).contains { line in - guard line.first?.isWhitespace != true else { return false } - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.hasPrefix("#") else { return false } - return conflictingKeys.contains { trimmed.hasPrefix("\($0):") } - } + return conflictingKeys.contains { self.topLevelYAMLSequenceHasEntries($0, in: text) } } private static func loadObservations( @@ -853,6 +848,33 @@ struct CLIProxyAPIAttributionResolver: Sendable { } extension CLIProxyAPIAttributionResolver { + private static func topLevelYAMLSequenceHasEntries(_ key: String, in text: String) -> Bool { + let lines = text.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline).map(String.init) + let prefix = "\(key):" + for (index, line) in lines.enumerated() { + guard line.first?.isWhitespace != true else { continue } + let structure = self.simpleYAMLScalar(line.trimmingCharacters(in: .whitespacesAndNewlines)) + guard structure.hasPrefix(prefix) else { continue } + + let inlineValue = structure.dropFirst(prefix.count).trimmingCharacters(in: .whitespaces) + if !inlineValue.isEmpty { + let compactValue = inlineValue.filter { !$0.isWhitespace } + return compactValue != "[]" && compactValue != "null" && compactValue != "~" + } + + for nestedLine in lines.dropFirst(index + 1) { + let nested = nestedLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard !nested.isEmpty, !nested.hasPrefix("#") else { continue } + guard nestedLine.first?.isWhitespace == true else { return false } + if self.simpleYAMLScalar(nested).hasPrefix("-") { + return true + } + } + return false + } + return false + } + private func closestUsageRecordMatch( observation: Observation, model: String, diff --git a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift index 564d76c838..754c12cb87 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift @@ -125,6 +125,68 @@ struct CLIProxyAPIAttributionResolverTests { #expect(attribution.evidence == [.cliProxyRequestLog, .modelProvider]) } + @Test + func `empty configured API upstream lists do not suppress codex auth inventory`() throws { + for (index, configuration) in [ + "codex-api-key: []\n", + "openai-compatibility: [] # no configured upstreams\n", + "codex-api-key:\n # no configured upstreams\nopenai-compatibility:\n", + ].enumerated() { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-empty-upstreams-\(index)-\(UUID().uuidString)", isDirectory: true) + let logs = root.appendingPathComponent("logs", isDirectory: true) + try fileManager.createDirectory(at: logs, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let timestamp = Date(timeIntervalSince1970: 1_784_179_200) + try Data(Self.requestLog(sessionID: "session-1", timestamp: timestamp).utf8) + .write(to: logs.appendingPathComponent("request.log")) + try Data(#"{"type":"codex"}"#.utf8).write(to: root.appendingPathComponent("codex.json")) + try Data(configuration.utf8).write(to: root.appendingPathComponent("config.yaml")) + + let resolver = try CLIProxyAPIAttributionResolver.load(home: root, fileManager: fileManager) + let attribution = resolver.attribution( + model: "gpt-5.5", + modelProvider: .openAI, + sessionID: "session-1", + timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), + tokens: Self.tokens) + + #expect(attribution.upstream?.provider == "codex") + #expect(attribution.evidence.contains(.cliProxyAuthInventory)) + } + } + + @Test + func `configured API upstream entry suppresses codex auth inventory`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-configured-upstream-\(UUID().uuidString)", isDirectory: true) + let logs = root.appendingPathComponent("logs", isDirectory: true) + try fileManager.createDirectory(at: logs, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let timestamp = Date(timeIntervalSince1970: 1_784_179_200) + try Data(Self.requestLog(sessionID: "session-1", timestamp: timestamp).utf8) + .write(to: logs.appendingPathComponent("request.log")) + try Data(#"{"type":"codex"}"#.utf8).write(to: root.appendingPathComponent("codex.json")) + try Data("openai-compatibility:\n - name: configured-upstream\n".utf8) + .write(to: root.appendingPathComponent("config.yaml")) + + let resolver = try CLIProxyAPIAttributionResolver.load(home: root, fileManager: fileManager) + let attribution = resolver.attribution( + model: "gpt-5.5", + modelProvider: .openAI, + sessionID: "session-1", + timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), + tokens: Self.tokens) + + #expect(attribution.route == .cliProxyAPI) + #expect(attribution.upstream == nil) + #expect(!attribution.evidence.contains(.cliProxyAuthInventory)) + } + @Test func `request telemetry identifies exact codex oauth upstream`() { let timestamp = Date(timeIntervalSince1970: 1_784_179_200) From 69266723e15765cd1db88c092d673055600bd20e Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 31 Aug 2026 09:07:28 -0700 Subject: [PATCH 178/183] Support bare YAML alias items --- .../CLIProxyAPIAttributionResolver.swift | 2 +- .../CLIProxyAPIAliasRegressionTests.swift | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift index 76713699e3..b21c9a4a1f 100644 --- a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift +++ b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift @@ -687,7 +687,7 @@ struct CLIProxyAPIAttributionResolver: Sendable { break } - let startsItem = trimmed.hasPrefix("- ") + let startsItem = trimmed == "-" || trimmed.hasPrefix("- ") if startsItem { if let currentName, let currentAlias, !currentName.isEmpty, !currentAlias.isEmpty { aliases[currentAlias] = currentName diff --git a/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift b/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift index 4578ce9a09..2f62bc918a 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift @@ -95,6 +95,21 @@ struct CLIProxyAPIAliasRegressionTests { ]) } + @Test + func `codex oauth alias parser accepts bare dash mapping items`() { + let configuration = """ + oauth-model-alias: + codex: + - + name: 'gpt-5.5' + alias: 'proxy-codex-alias' + """ + + #expect(CLIProxyAPIAttributionResolver.parseCodexOAuthModelAliases(configuration) == [ + "proxy-codex-alias": "gpt-5.5", + ]) + } + @Test func `weaker live route evidence preserves cached telemetry upstream`() { let cached = CostUsageAttribution( From f69185fdc7bd06db3894053b439c1d1db10092de Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Mon, 31 Aug 2026 18:39:12 -0700 Subject: [PATCH 179/183] Support flow-style YAML aliases --- .../CLIProxyAPIAttributionResolver.swift | 77 ++++++++++++++++++- .../CLIProxyAPIAliasRegressionTests.swift | 15 ++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift index b21c9a4a1f..087821518a 100644 --- a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift +++ b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift @@ -700,7 +700,10 @@ struct CLIProxyAPIAttributionResolver: Sendable { continue } let field = startsItem ? String(trimmed.dropFirst(2)) : trimmed - if field.hasPrefix("name:") { + if let flowMapping = self.simpleYAMLFlowMapping(field) { + currentName = flowMapping["name"] + currentAlias = flowMapping["alias"] + } else if field.hasPrefix("name:") { currentName = self.simpleYAMLScalar(String(field.dropFirst("name:".count))) } else if field.hasPrefix("alias:") { currentAlias = self.simpleYAMLScalar(String(field.dropFirst("alias:".count))) @@ -848,6 +851,78 @@ struct CLIProxyAPIAttributionResolver: Sendable { } extension CLIProxyAPIAttributionResolver { + private static func simpleYAMLFlowMapping(_ raw: String) -> [String: String]? { + let trimmed = raw.trimmingCharacters(in: .whitespaces) + guard trimmed.first == "{" else { return nil } + + var fields: [String] = [] + var current = "" + var quote: Character? + var escaped = false + var reachedEnd = false + for character in trimmed.dropFirst() { + if let activeQuote = quote { + current.append(character) + if activeQuote == "\"", character == "\\", !escaped { + escaped = true + continue + } + if character == activeQuote, !escaped { + quote = nil + } + escaped = false + continue + } + if character == "\"" || character == "'" { + quote = character + current.append(character) + } else if character == "," { + fields.append(current) + current = "" + } else if character == "}" { + fields.append(current) + reachedEnd = true + break + } else { + current.append(character) + } + } + guard reachedEnd, quote == nil else { return nil } + + var mapping: [String: String] = [:] + for field in fields { + guard let separator = self.firstUnquotedColon(in: field) else { continue } + let key = self.simpleYAMLScalar(String(field[.. String.Index? { + var quote: Character? + var escaped = false + for index in value.indices { + let character = value[index] + if let activeQuote = quote { + if activeQuote == "\"", character == "\\", !escaped { + escaped = true + continue + } + if character == activeQuote, !escaped { + quote = nil + } + escaped = false + } else if character == "\"" || character == "'" { + quote = character + } else if character == ":" { + return index + } + } + return nil + } + private static func topLevelYAMLSequenceHasEntries(_ key: String, in text: String) -> Bool { let lines = text.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline).map(String.init) let prefix = "\(key):" diff --git a/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift b/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift index 2f62bc918a..cefa04a46f 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift @@ -110,6 +110,21 @@ struct CLIProxyAPIAliasRegressionTests { ]) } + @Test + func `codex oauth alias parser accepts flow mappings`() { + let configuration = """ + oauth-model-alias: + codex: + - {name: 'gpt-5.5', alias: 'proxy-codex-alias'} + - {alias: "proxy,codex-second", name: "gpt-5.6"} # local alias + """ + + #expect(CLIProxyAPIAttributionResolver.parseCodexOAuthModelAliases(configuration) == [ + "proxy-codex-alias": "gpt-5.5", + "proxy,codex-second": "gpt-5.6", + ]) + } + @Test func `weaker live route evidence preserves cached telemetry upstream`() { let cached = CostUsageAttribution( From b27b03049be1d44f4061cb5ecbb0e3d6f21eeea3 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 1 Sep 2026 06:36:27 -0700 Subject: [PATCH 180/183] Isolate proxy attribution after credential loss --- Sources/CodexBar/UsageStore+CLIProxyAPI.swift | 3 +- ...CLIProxyAPIUsageStoreTransitionTests.swift | 38 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift index cd977a181e..d5782e70ef 100644 --- a/Sources/CodexBar/UsageStore+CLIProxyAPI.swift +++ b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift @@ -173,7 +173,8 @@ extension UsageStore { return collectorState } if collectorState.configurationAvailability == .available || - (collectorState.configurationAvailability == .unknown && explicitlyDisconnected) || + (collectorState.configurationAvailability == .unknown && + (explicitlyDisconnected || collectorState.configurationGeneration != nil)) || collectorState.configurationTransitionPending { guard await publishAttributionIsolation(currentGeneration) else { diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTransitionTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTransitionTests.swift index 590af77b9e..96a8298a4b 100644 --- a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTransitionTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTransitionTests.swift @@ -4,6 +4,44 @@ import Testing @testable import CodexBar extension CLIProxyAPIUsageStoreTests { + @Test + func `startup missing credential isolates attribution from the former configuration`() async { + let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") + settings.costUsageEnabled = true + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + store.publishTokenSnapshot(Self.tokenSnapshot(), for: .codex) + store.publishTokenSnapshot(Self.tokenSnapshot(), for: .claude) + var isolationGenerations: [String?] = [] + var refreshes: [(UsageProvider, Bool)] = [] + + let collectorState = await store.handleCLIProxyAPIUsageCollectionResult( + .notConfigured, + collectorState: CLIProxyAPIUsageCollectorState( + configurationGeneration: "former-generation"), + isExplicitlyDisconnected: { false }, + publishAttributionIsolation: { expectedGeneration in + isolationGenerations.append(expectedGeneration) + return true + }, + configurationGeneration: { "former-generation" }, + refresh: { provider, force in + refreshes.append((provider, force)) + }) + + #expect(collectorState.configurationAvailability == .unavailable) + #expect(collectorState.configurationGeneration == "former-generation") + #expect(isolationGenerations == ["former-generation"]) + #expect(store.tokenSnapshot(for: .codex) == nil) + #expect(store.tokenSnapshot(for: .claude) == nil) + #expect(refreshes.map(\.0) == [.claude, .codex]) + #expect(refreshes.map(\.1) == [true, true]) + } + @Test func `stale collector replacement stays pending without publishing disconnect isolation`() async { let settings = testSettingsStore(suiteName: "CLIProxyAPIUsageStoreTests-\(UUID().uuidString)") From 7c19e3afbf464b30adeaf6e76bf33849c59b484c Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 1 Sep 2026 07:50:00 -0700 Subject: [PATCH 181/183] Skip unrelated proxy attribution scans --- .../UsageStore+SpendDashboardTokenCost.swift | 26 +++++++++++++++---- .../SpendDashboardCodexProxySourceTests.swift | 8 ++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBar/UsageStore+SpendDashboardTokenCost.swift b/Sources/CodexBar/UsageStore+SpendDashboardTokenCost.swift index 09ec221e66..7487829457 100644 --- a/Sources/CodexBar/UsageStore+SpendDashboardTokenCost.swift +++ b/Sources/CodexBar/UsageStore+SpendDashboardTokenCost.swift @@ -16,6 +16,13 @@ extension UsageStore { && provider != .codex } + nonisolated static func spendDashboardTokenUsageNeedsCLIProxyAPIAttributionGuard( + _ provider: UsageProvider) -> Bool + { + // Provider-specific by design: only Claude and Codex snapshots can contain CLIProxyAPI attribution. + provider == .claude || provider == .codex + } + func spendDashboardTokenSnapshotPublicationForCurrentConfig( for provider: UsageProvider) -> CurrentProviderConfigTokenPublication? { @@ -121,7 +128,13 @@ extension UsageStore { return } - let cliProxyAPIAttributionGuard = await self.captureCLIProxyAPIAttributionPublicationGuard() + let cliProxyAPIAttributionGuard: CLIProxyAPIAttributionPublicationGuard? = if Self + .spendDashboardTokenUsageNeedsCLIProxyAPIAttributionGuard(provider) + { + await self.captureCLIProxyAPIAttributionPublicationGuard() + } else { + nil + } do { let snapshot = try await self.loadTokenUsageSnapshot( provider: provider, @@ -237,15 +250,18 @@ extension UsageStore { provider: UsageProvider, publicationRevision: ProviderPublicationRevision, providerConfigRevision: UInt64, - cliProxyAPIAttributionGuard: CLIProxyAPIAttributionPublicationGuard, + cliProxyAPIAttributionGuard: CLIProxyAPIAttributionPublicationGuard?, costScopeSignature: String, fetchedCredentialScopeFingerprint: String? = nil) async -> Bool { + let cliProxyAPIAttributionIsCurrent = if let cliProxyAPIAttributionGuard { + await self.cliProxyAPIAttributionPublicationIsCurrentOffMain(cliProxyAPIAttributionGuard, for: provider) + } else { + true + } guard self.providerPublicationRevisionIsCurrent(publicationRevision, for: provider), self.settings.providerConfigRevision(for: provider) == providerConfigRevision, - await self.cliProxyAPIAttributionPublicationIsCurrentOffMain( - cliProxyAPIAttributionGuard, - for: provider), + cliProxyAPIAttributionIsCurrent, self.settings.costUsageEnabled, self.isEnabled(provider) else { diff --git a/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift b/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift index 6a0b7f506c..66ef915108 100644 --- a/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift +++ b/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift @@ -5,6 +5,14 @@ import Testing @MainActor struct SpendDashboardCodexProxySourceTests { + @Test + func `unrelated dashboard providers skip proxy attribution guard capture`() { + #expect(!UsageStore.spendDashboardTokenUsageNeedsCLIProxyAPIAttributionGuard(.cursor)) + #expect(!UsageStore.spendDashboardTokenUsageNeedsCLIProxyAPIAttributionGuard(.gemini)) + #expect(UsageStore.spendDashboardTokenUsageNeedsCLIProxyAPIAttributionGuard(.claude)) + #expect(UsageStore.spendDashboardTokenUsageNeedsCLIProxyAPIAttributionGuard(.codex)) + } + @Test func `proxy usage loads once beside account scoped codex snapshots`() async { let now = Date(timeIntervalSince1970: 1_784_179_200) From dfb6159bdd005e818a34dbc17eca74555b0ce819 Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 1 Sep 2026 08:38:30 -0700 Subject: [PATCH 182/183] Parse quoted proxy alias keys --- .../CLIProxyAPIAttributionResolver.swift | 13 ++++++++++--- .../CLIProxyAPIAliasRegressionTests.swift | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift index 087821518a..8da823d88e 100644 --- a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift +++ b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift @@ -661,11 +661,11 @@ struct CLIProxyAPIAttributionResolver: Sendable { let line = String(rawLine) let trimmed = line.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty, !trimmed.hasPrefix("#") else { continue } - let structure = self.simpleYAMLScalar(trimmed) + let structureKey = self.simpleYAMLMappingKey(trimmed) let indent = line.prefix { $0 == " " }.count if rootIndent == nil { - if structure == "oauth-model-alias:" { + if structureKey == "oauth-model-alias" { rootIndent = indent } continue @@ -676,7 +676,8 @@ struct CLIProxyAPIAttributionResolver: Sendable { break } if codexIndent == nil { - if structure == "codex:" { + // Provider-specific by design: this parser reads only CLIProxyAPI's Codex OAuth alias section. + if structureKey == "codex" { codexIndent = indent } continue @@ -851,6 +852,12 @@ struct CLIProxyAPIAttributionResolver: Sendable { } extension CLIProxyAPIAttributionResolver { + private static func simpleYAMLMappingKey(_ raw: String) -> String? { + guard let separator = self.firstUnquotedColon(in: raw) else { return nil } + let key = self.simpleYAMLScalar(String(raw[.. [String: String]? { let trimmed = raw.trimmingCharacters(in: .whitespaces) guard trimmed.first == "{" else { return nil } diff --git a/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift b/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift index cefa04a46f..2fa39a3e56 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift @@ -125,6 +125,20 @@ struct CLIProxyAPIAliasRegressionTests { ]) } + @Test + func `codex oauth alias parser accepts quoted section keys`() { + let configuration = """ + "oauth-model-alias": + 'codex': + - name: 'gpt-5.5' + alias: 'proxy-codex-alias' + """ + + #expect(CLIProxyAPIAttributionResolver.parseCodexOAuthModelAliases(configuration) == [ + "proxy-codex-alias": "gpt-5.5", + ]) + } + @Test func `weaker live route evidence preserves cached telemetry upstream`() { let cached = CostUsageAttribution( From b5bee207f307ee8ab9f1b34c44c58bf58c9acadf Mon Sep 17 00:00:00 2001 From: Ethan Clinick Date: Tue, 1 Sep 2026 09:01:39 -0700 Subject: [PATCH 183/183] Recognize quoted proxy upstream keys --- .../CLIProxyAPIAttributionResolver.swift | 9 ++--- .../CLIProxyAPIAttributionResolverTests.swift | 33 +++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift index 8da823d88e..833714eabd 100644 --- a/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift +++ b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift @@ -932,13 +932,14 @@ extension CLIProxyAPIAttributionResolver { private static func topLevelYAMLSequenceHasEntries(_ key: String, in text: String) -> Bool { let lines = text.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline).map(String.init) - let prefix = "\(key):" for (index, line) in lines.enumerated() { guard line.first?.isWhitespace != true else { continue } - let structure = self.simpleYAMLScalar(line.trimmingCharacters(in: .whitespacesAndNewlines)) - guard structure.hasPrefix(prefix) else { continue } + let structure = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard self.simpleYAMLMappingKey(structure) == key, + let separator = self.firstUnquotedColon(in: structure) + else { continue } - let inlineValue = structure.dropFirst(prefix.count).trimmingCharacters(in: .whitespaces) + let inlineValue = self.simpleYAMLScalar(String(structure[structure.index(after: separator)...])) if !inlineValue.isEmpty { let compactValue = inlineValue.filter { !$0.isWhitespace } return compactValue != "[]" && compactValue != "null" && compactValue != "~" diff --git a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift index 754c12cb87..4163191e09 100644 --- a/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift +++ b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift @@ -187,6 +187,39 @@ struct CLIProxyAPIAttributionResolverTests { #expect(!attribution.evidence.contains(.cliProxyAuthInventory)) } + @Test + func `quoted configured API upstream keys suppress codex auth inventory`() throws { + for (index, configuration) in [ + "\"openai-compatibility\":\n - name: configured-upstream\n", + "'codex-api-key':\n - api-key: configured-upstream\n", + ].enumerated() { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-quoted-upstream-\(index)-\(UUID().uuidString)", isDirectory: true) + let logs = root.appendingPathComponent("logs", isDirectory: true) + try fileManager.createDirectory(at: logs, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let timestamp = Date(timeIntervalSince1970: 1_784_179_200) + try Data(Self.requestLog(sessionID: "session-1", timestamp: timestamp).utf8) + .write(to: logs.appendingPathComponent("request.log")) + try Data(#"{"type":"codex"}"#.utf8).write(to: root.appendingPathComponent("codex.json")) + try Data(configuration.utf8).write(to: root.appendingPathComponent("config.yaml")) + + let resolver = try CLIProxyAPIAttributionResolver.load(home: root, fileManager: fileManager) + let attribution = resolver.attribution( + model: "gpt-5.5", + modelProvider: .openAI, + sessionID: "session-1", + timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), + tokens: Self.tokens) + + #expect(attribution.route == .cliProxyAPI) + #expect(attribution.upstream == nil) + #expect(!attribution.evidence.contains(.cliProxyAuthInventory)) + } + } + @Test func `request telemetry identifies exact codex oauth upstream`() { let timestamp = Date(timeIntervalSince1970: 1_784_179_200)