diff --git a/CHANGELOG.md b/CHANGELOG.md index e1d005304a..52881a4301 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ - Cursor: estimate API-rate costs when usage events omit prices, using cached or bundled pricing while keeping unknown costs unpriced and estimates separate from Cursor-metered charges (#3129). Thanks @Yuxin-Qiao! ### 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. - Cursor: show empty cost-history windows without a decoding error, while preserving incomplete-history and malformed-response checks. - Codex: honor the native access token's expiry so valid OAuth sessions keep model-specific limits, while leaving credential refresh under CLI ownership (#3221, #3222). Thanks @anagnorisis2peripeteia! - Codex: clear stale connectivity errors after a successful fetch, even when weekly usage cannot yet be published, including persisted and stacked-account errors (#3214). Thanks @olddonkey! diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index 9447ccd29f..2286099673 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -2,6 +2,16 @@ 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 .other: "Other model provider via Claude Code" + case .unknown: "Unknown model provider via Claude Code" + } +} + @MainActor // swiftlint:disable:next type_body_length struct CostHistoryChartMenuView: View { @@ -729,7 +739,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 { @@ -930,26 +940,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 - } - - return lhs.modelName > rhs.modelName - } - } - static func detailViewportRowCount(itemCount: Int) -> Int { min(max(itemCount, 0), self.maxVisibleDetailLines) } @@ -972,6 +962,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 = "\(L("Std")) \(self.costString(standardCost))" if let standardTokens = item.standardTokens { @@ -1022,6 +1025,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 + } + } + static func projectIdentity( _ project: CostUsageProjectBreakdown, ordinal: Int, @@ -1070,6 +1099,7 @@ extension CostHistoryChartMenuView { struct VisibleModelBreakdownFingerprint: Equatable { let modelName: String + let attribution: CostUsageAttribution? let costBitPattern: UInt64? let totalTokens: Int? let standardCostBitPattern: UInt64? @@ -1163,6 +1193,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), @@ -1182,6 +1213,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 b29ec8650f..d66b6f196b 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -32,6 +32,45 @@ 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 spendDashboardCodexAccountRowCount(_ rows: [SpendDashboardModel.ProviderRow]) -> Int { + // Provider-specific by design: the synthetic CLIProxyAPI row is not a Codex account subscription. + 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 +{ + 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 .other: "Other model provider" + case .unknown: "Unknown model provider" + } + return "\(providerName) · \(modelProvider) via Claude Code" + } +} + func spendDashboardTokenMixValue(_ value: Int?) -> String { value.map(UsageFormatter.tokenCountString) ?? "—" } @@ -138,10 +177,30 @@ 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 .interactionRequired, .temporarilyUnavailable: + (currentBaseURL, hasSavedConfiguration) + } +} + @MainActor struct SpendDashboardPane: View { @Bindable var settings: SettingsStore @Bindable var store: UsageStore + @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 @State private var isVisible = false @State private var userSelectedBackground = false @@ -156,6 +215,7 @@ struct SpendDashboardPane: View { self.header self.codexCostCatchUpPanel self.content + self.cliProxyAPISetup self.provenance self.shareAction } @@ -165,6 +225,7 @@ struct SpendDashboardPane: View { .onAppear { self.isVisible = true self.controller.update(configuration: self.configuration) + self.loadCLIProxyAPIConfiguration() if !self.controller.isRefreshing { self.synchronizeCodexCostCatchUp() } @@ -463,6 +524,150 @@ 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("Local 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) { + Task { await 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. Records are retained for up to " + + "366 days; Remove and Clear cost cache delete both retained and pending records.") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + + private func loadCLIProxyAPIConfiguration() { + let presentation = spendDashboardCLIProxyAPIConfigurationPresentation( + loadResult: CLIProxyAPIConnectionSettingsStore.loadResult(), + currentBaseURL: self.cliProxyAPIBaseURL, + hasSavedConfiguration: self.cliProxyAPIHasSavedConfiguration) + self.cliProxyAPIBaseURL = presentation.baseURL + self.cliProxyAPIHasSavedConfiguration = presentation.hasSavedConfiguration + } + + 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 loopback URL and management key." + return + } + let collectorTask = self.store.stopCLIProxyAPIUsageCollector() + await collectorTask?.value + 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 + } + + 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." + 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)" + } + await self.store.refreshCLIProxyAPICostAttribution() + self.store.startCLIProxyAPIUsageCollector() + } + + private func removeCLIProxyAPIConfiguration() async { + self.cliProxyAPIIsSaving = true + defer { self.cliProxyAPIIsSaving = false } + + switch await self.store.removeCLIProxyAPIConfiguration() { + case .removed: + 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." + case .telemetryCleanupFailed: + self.cliProxyAPIManagementKey = "" + self.cliProxyAPIHasSavedConfiguration = false + self.cliProxyAPIStatus = "Configuration removed, but some local telemetry could not be deleted." + self.controller.refresh() + } + } + private var shareAction: some View { HStack { Button { @@ -508,11 +713,11 @@ 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 { + // Provider-specific by design: Codex account rows can inherit their account-scoped subscription. let snapshots: [UsageSnapshot?] = if row.provider == .codex, row.id.hasPrefix("codex:") { @@ -520,7 +725,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.instanceID)] @@ -592,7 +801,8 @@ struct SpendDashboardCurrencySection: View { } SpendSummaryValue( title: L("Subscriptions"), - value: codexBarLocalizedInteger(self.group.providers.count)) + value: codexBarLocalizedInteger( + spendDashboardSubscriptionCount(self.group.providers))) Spacer() } HStack(spacing: 24) { @@ -736,7 +946,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(spendDashboardMetricText( @@ -1169,6 +1383,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 { @@ -1192,16 +1407,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/Sources/CodexBar/ShareStatsPayload.swift b/Sources/CodexBar/ShareStatsPayload.swift index 01f736dd01..2ab6a0839c 100644 --- a/Sources/CodexBar/ShareStatsPayload.swift +++ b/Sources/CodexBar/ShareStatsPayload.swift @@ -255,9 +255,15 @@ 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.map { row in - ShareStatsProviderPayload( + group.providers.compactMap { row -> ShareStatsProviderPayload? in + // 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, subscriptionName: subscriptionNames[row.id]?.displayName, @@ -272,12 +278,13 @@ 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 } return ShareStatsModelPayload( - provider: row.provider, - providerName: row.providerName, + provider: sharedProvider.provider, + providerName: sharedProvider.name, modelName: modelName, currencyCode: group.currencyCode, totalTokens: row.totalTokens, @@ -331,6 +338,33 @@ 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)? + { + 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() + // Provider-specific by design: CLIProxyAPI provider strings map to CodexBar's canonical provider IDs. + 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/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index e007fb6765..f1e0923431 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -1,14 +1,17 @@ -import CodexBarCore // swiftlint:disable file_length +import CodexBarCore import CryptoKit import Foundation import Observation +// swiftlint:disable file_length + struct SpendDashboardConfiguration: Equatable, Sendable { let costUsageEnabled: Bool let preferredCurrencyCode: String let providerIDs: [String] let codexAccountIdentities: [String] let codexAccountDisplayNames: [String: String] + let cliProxyAPIConfigurationGeneration: String? let sourceOwnershipFingerprints: [String] let sourceRevisions: [String] let bucketTimeZoneIdentifier: String @@ -23,6 +26,7 @@ struct SpendDashboardConfiguration: Equatable, Sendable { providerIDs: [String], codexAccountIdentities: [String], codexAccountDisplayNames: [String: String] = [:], + cliProxyAPIConfigurationGeneration: String? = nil, sourceOwnershipFingerprints: [String] = [], sourceRevisions: [String] = [], bucketTimeZoneIdentifier: String = "", @@ -36,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 @@ -156,11 +161,22 @@ struct CodexSpendSnapshotLoadContext: Sendable { let calendar: Calendar } -enum SpendDashboardSource { +struct CodexProxySpendSnapshotLoadContext: Sendable { + let now: Date + let force: Bool + let historyDays: Int + let refreshPricingInBackground: Bool + let calendar: Calendar +} + +enum SpendDashboardSource { // swiftlint:disable:this type_body_length typealias CodexSnapshotLoader = @Sendable (CodexSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot typealias CodexActivityLoader = @Sendable (CodexSpendSnapshotLoadContext) async -> CostUsageTokenActivityCache? + typealias CodexProxySnapshotLoader = @Sendable (CodexProxySpendSnapshotLoadContext) async throws + -> CostUsageTokenSnapshot + typealias CodexProxyAttributionGuardLoader = @Sendable () -> CLIProxyAPIAttributionPublicationGuard typealias CachedCodexSnapshotLoader = @Sendable (CodexSpendSnapshotLoadContext) async -> CostUsageTokenSnapshot? typealias CodexCacheRootResolver = @Sendable (CodexSpendScanRequest) -> URL @@ -168,6 +184,14 @@ enum SpendDashboardSource { static let activityDays = 365 /// Local spend scan window. Matches token-activity depth so 7d / 30d / All share one snapshot. static let scanDays = activityDays + 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 { @@ -190,12 +214,22 @@ enum SpendDashboardSource { providers: [UsageProvider], codexSources: [CodexSpendSourceDescriptor]) -> SpendDashboardConfiguration { - SpendDashboardConfiguration( + var codexDisplayNames = self.codexDisplayNamesByID(codexSources) + // Provider-specific by design: CLIProxyAPI is exposed as a synthetic source in the Codex spend family. + if providers.contains(.codex) { + let providerName = store.metadata(for: .codex).displayName + codexDisplayNames[Self.codexProxySourceID] = "\(providerName) · CLIProxyAPI" + } + return SpendDashboardConfiguration( costUsageEnabled: self.spendCollectionEnabled(settings: settings, providers: providers), preferredCurrencyCode: settings.preferredCurrencyCode, providerIDs: providers.map(\.rawValue), codexAccountIdentities: codexSources.map(\.identity), - codexAccountDisplayNames: self.codexDisplayNamesByID(codexSources), + codexAccountDisplayNames: codexDisplayNames, + cliProxyAPIConfigurationGeneration: self.shouldLoadCodexProxy( + providerIDs: providers.map(\.rawValue)) + ? CostUsageCacheLocations.cliProxyAPIConfigurationGeneration() + : nil, sourceOwnershipFingerprints: self.sourceOwnershipFingerprints( providers: providers, settings: settings, @@ -355,11 +389,18 @@ 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, cacheRootResolver: { self.codexCacheRoot(for: $0) }, codexSnapshotLoader: { context in try await self.loadCodexSnapshot(context) }, - codexActivityLoader: { context in await self.loadCodexActivity(context) }) + codexActivityLoader: { context in await self.loadCodexActivity(context) }, + codexProxySnapshotLoader: codexProxySnapshotLoader, + codexProxyAttributionGuardLoader: self.loadCodexProxyAttributionGuard) } static func load( @@ -371,7 +412,8 @@ enum SpendDashboardSource { request, cacheRootResolver: cacheRootResolver, codexSnapshotLoader: codexSnapshotLoader, - codexActivityLoader: { context in await self.loadCodexActivity(context) }) + codexActivityLoader: { context in await self.loadCodexActivity(context) }, + codexProxySnapshotLoader: nil) } static func loadCached(_ request: SpendDashboardLoadRequest) async -> SpendDashboardLoadResult { @@ -449,7 +491,8 @@ enum SpendDashboardSource { request, cacheRootResolver: { self.codexCacheRoot(for: $0) }, codexSnapshotLoader: codexSnapshotLoader, - codexActivityLoader: { _ in nil }) + codexActivityLoader: { _ in nil }, + codexProxySnapshotLoader: nil) } static func load( @@ -461,14 +504,44 @@ enum SpendDashboardSource { request, cacheRootResolver: { self.codexCacheRoot(for: $0) }, codexSnapshotLoader: codexSnapshotLoader, - codexActivityLoader: codexActivityLoader) + codexActivityLoader: codexActivityLoader, + codexProxySnapshotLoader: nil) + } + + static func load( + _ request: SpendDashboardLoadRequest, + codexSnapshotLoader: @escaping CodexSnapshotLoader, + codexProxySnapshotLoader: CodexProxySnapshotLoader?, + codexProxyAttributionGuardLoader: @escaping CodexProxyAttributionGuardLoader = { + CLIProxyAPIAttributionPublicationGuard( + configurationGeneration: nil, + telemetryRevision: nil, + inputArtifactFingerprint: nil, + isIsolated: false) + }) async -> SpendDashboardLoadResult + { + await self.load( + request, + cacheRootResolver: { self.codexCacheRoot(for: $0) }, + codexSnapshotLoader: codexSnapshotLoader, + codexActivityLoader: { _ in nil }, + codexProxySnapshotLoader: codexProxySnapshotLoader, + codexProxyAttributionGuardLoader: codexProxyAttributionGuardLoader) } private static func load( _ request: SpendDashboardLoadRequest, cacheRootResolver: @escaping CodexCacheRootResolver, codexSnapshotLoader: @escaping CodexSnapshotLoader, - codexActivityLoader: @escaping CodexActivityLoader) async -> SpendDashboardLoadResult + codexActivityLoader: @escaping CodexActivityLoader, + codexProxySnapshotLoader: CodexProxySnapshotLoader?, + codexProxyAttributionGuardLoader: @escaping CodexProxyAttributionGuardLoader = { + CLIProxyAPIAttributionPublicationGuard( + configurationGeneration: nil, + telemetryRevision: nil, + inputArtifactFingerprint: nil, + isIsolated: false) + }) async -> SpendDashboardLoadResult { var inputs = request.capturedInputs var failedSourceIDs = request.unavailableSourceIDs @@ -562,6 +635,45 @@ enum SpendDashboardSource { } catch {} } } + if self.shouldLoadCodexProxy(providerIDs: request.configuration.providerIDs), + let codexProxySnapshotLoader + { + do { + let attributionGuard = codexProxyAttributionGuardLoader() + let snapshot = try await codexProxySnapshotLoader(CodexProxySpendSnapshotLoadContext( + now: request.now, + force: request.force, + historyDays: Self.scanDays, + refreshPricingInBackground: false, + calendar: request.configuration.bucketCalendar)) + try Task.checkCancellation() + 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( + id: Self.codexProxySourceID, + provider: .codex, + displayName: "\(providerName) · CLIProxyAPI", + modelProviderName: providerName, + snapshot: snapshot, + sourceKind: .cliProxyAPI)) + } + } catch is CancellationError { + inputs.removeAll { $0.id == Self.codexProxySourceID } + failedSourceIDs.insert(Self.codexProxySourceID) + invalidatedSourceIDs.insert(Self.codexProxySourceID) + return SpendDashboardLoadResult( + inputs: inputs, + failedSourceIDs: failedSourceIDs, + invalidatedSourceIDs: invalidatedSourceIDs) + } catch { + failedSourceIDs.insert(Self.codexProxySourceID) + } + } let lateInvalidatedSourceIDs = Set(request.codexRequests.compactMap { account in self.codexAuthFingerprintMatches(account) ? nil @@ -599,6 +711,7 @@ enum SpendDashboardSource { private static func loadCodexSnapshot( _ context: CodexSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot { + // Provider-specific by design: each managed Codex home receives an isolated transcript scan. try await CostUsageFetcher(cacheRoot: context.cacheRoot, calendar: context.calendar).loadTokenSnapshot( provider: .codex, environment: CodexHomeScope.scopedEnvironment(base: [:], codexHome: context.account.homePath), @@ -607,7 +720,33 @@ 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(calendar: context.calendar).loadCodexProxyTokenSnapshot( + now: context.now, + forceRefresh: context.force, + historyDays: context.historyDays, + refreshPricingInBackground: context.refreshPricingInBackground) + } + + private static func loadCodexProxyAttributionGuard() -> CLIProxyAPIAttributionPublicationGuard { + let fetcher = CostUsageFetcher() + return CLIProxyAPIAttributionPublicationGuard( + configurationGeneration: fetcher.cliProxyAPIConfigurationGeneration(), + telemetryRevision: fetcher.cliProxyAPIUsageTelemetryRevision(), + inputArtifactFingerprint: fetcher.cliProxyAPIInputArtifactFingerprint(), + 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) + || providerIDs.contains(UsageProvider.claude.rawValue) } private static func loadCodexActivity( @@ -776,6 +915,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) @@ -840,7 +989,7 @@ enum SpendDashboardSource { store: UsageStore) -> [String] { providers.compactMap { provider in - // Provider-specific by design: spend dashboard + // Provider-specific by design: Codex ownership is represented by account-scoped source fingerprints. guard provider != .codex else { return nil } var config = settings.providerConfig(for: provider) ?? ProviderConfig(id: provider.instanceID) config.enabled = nil @@ -1525,7 +1674,12 @@ 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 SpendDashboardSource.shouldLoadCodexProxy( + providerIDs: outcome.request.configuration.providerIDs) + { + forcedCodexIDs.insert(SpendDashboardSource.codexProxySourceID) + } let confirmedNonemptyInputs = outcome.confirmedNonemptyInputs let confirmedNonemptyIDs = Set(confirmedNonemptyInputs.map(\.id)) var inputs = capture.capturedInputs.filter { @@ -1667,11 +1821,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, @@ -1719,6 +1878,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 } @@ -1736,6 +1896,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) } @@ -1784,6 +1945,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 && @@ -1800,6 +1962,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, @@ -1828,7 +1991,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/Sources/CodexBar/SpendDashboardModel+ModelBreakdown.swift b/Sources/CodexBar/SpendDashboardModel+ModelBreakdown.swift index 7d76f0b6e8..9fa9d08365 100644 --- a/Sources/CodexBar/SpendDashboardModel+ModelBreakdown.swift +++ b/Sources/CodexBar/SpendDashboardModel+ModelBreakdown.swift @@ -7,6 +7,7 @@ extension SpendDashboardModel { private struct ModelKey: Hashable { let provider: UsageProvider let modelName: String + let attribution: CostUsageAttribution? } private struct ModelAccumulator { @@ -44,7 +45,10 @@ extension SpendDashboardModel { 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, @@ -90,7 +94,8 @@ extension SpendDashboardModel { modelName: key.modelName, totalTokens: value.sawTokens && !value.invalidTokens && !value.overflowedTokens ? value.tokens : nil, totalCost: value.sawCost && !value.invalidCost && !value.overflowedCost ? value.cost : nil, - tokenMix: value.mix) + tokenMix: value.mix, + attribution: key.attribution) } .sorted { lhs, rhs in switch (lhs.totalCost, rhs.totalCost) { @@ -101,7 +106,11 @@ extension SpendDashboardModel { 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 + } + return (lhs.attribution?.deterministicSortKey ?? "") + < (rhs.attribution?.deterministicSortKey ?? "") } } .enumerated() @@ -113,7 +122,8 @@ extension SpendDashboardModel { modelName: row.modelName, totalTokens: row.totalTokens, totalCost: row.totalCost, - tokenMix: row.tokenMix) + tokenMix: row.tokenMix, + attribution: row.attribution) } return ModelSummary(rows: rows, completeness: completeness) } diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index f62c9c450b..11dc1efb70 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" @@ -75,6 +76,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 @@ -82,9 +89,10 @@ struct SpendDashboardModel: Equatable, Sendable { let totalTokens: Int? let totalCost: Double? let tokenMix: CostUsageTokenMix + let attribution: CostUsageAttribution? - var id: String { - "\(self.provider.rawValue):\(self.modelName)" + var id: ID { + ID(provider: self.provider, modelName: self.modelName, attribution: self.attribution) } init( @@ -94,7 +102,8 @@ struct SpendDashboardModel: Equatable, Sendable { modelName: String, totalTokens: Int?, totalCost: Double?, - tokenMix: CostUsageTokenMix = CostUsageTokenMix()) + tokenMix: CostUsageTokenMix = CostUsageTokenMix(), + attribution: CostUsageAttribution? = nil) { self.rank = rank self.provider = provider @@ -103,6 +112,7 @@ struct SpendDashboardModel: Equatable, Sendable { self.totalTokens = totalTokens self.totalCost = totalCost self.tokenMix = tokenMix + self.attribution = attribution } } diff --git a/Sources/CodexBar/SpendDashboardPublication.swift b/Sources/CodexBar/SpendDashboardPublication.swift index 087f27218b..49d9118926 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,12 @@ 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) + // 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)) + } ?? true + return isInProviderScope && !staleSourceIDs.contains(input.id) } return SpendDashboardModel.build( inputs: inputs, 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/Sources/CodexBar/UsageStore+CLIProxyAPI.swift b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift new file mode 100644 index 0000000000..d5782e70ef --- /dev/null +++ b/Sources/CodexBar/UsageStore+CLIProxyAPI.swift @@ -0,0 +1,251 @@ +import CodexBarCore +import Foundation + +struct CLIProxyAPIUsageCollectorState: Equatable { + enum ConfigurationAvailability: Equatable { + case unknown + case available + case unavailable + } + + var configurationAvailability: ConfigurationAvailability = .unknown + var configurationGeneration: String? + var telemetryRevision: String? + var configurationTransitionPending = false +} + +@MainActor +extension UsageStore { + private static let cliProxyAPIUsageCollectionInterval: Duration = .seconds(30) + private static let cliProxyAPIPendingPruneInterval: Duration = .seconds(24 * 60 * 60) + + func startCLIProxyAPIUsageCollector( + initialConfigurationGeneration: String? = nil, + collectionInterval: Duration? = nil, + pendingPruneInterval: Duration? = nil, + failedPruneRetryInterval: Duration? = nil, + maintenance: (@Sendable () -> Bool)? = nil, + collector: (@Sendable () async -> CLIProxyAPIUsageCollectionResult)? = nil) + { + self.stopCLIProxyAPIUsageCollector() + self.cliProxyAPICleanupRetryTask?.cancel() + self.cliProxyAPICleanupRetryTask = nil + let collectionInterval = collectionInterval ?? Self.cliProxyAPIUsageCollectionInterval + let pendingPruneInterval = pendingPruneInterval ?? Self.cliProxyAPIPendingPruneInterval + let failedPruneRetryInterval = failedPruneRetryInterval ?? Self.cliProxyAPIUsageCollectionInterval + let maintenance = maintenance ?? { + CLIProxyAPIUsageCollector.pruneExpiredUsage() + } + let initialConfigurationGeneration = initialConfigurationGeneration ?? + CostUsageCacheLocations.cliProxyAPIConfigurationGeneration() + self.cliProxyAPIUsageCollectorTask = Task.detached(priority: .utility) { [weak self] in + var nextPendingPruneAt: ContinuousClock.Instant? + var collectorState = CLIProxyAPIUsageCollectorState( + configurationGeneration: initialConfigurationGeneration, + telemetryRevision: CLIProxyAPIUsageTelemetryRevision.current()) + while !Task.isCancelled { + let now = ContinuousClock.now + if nextPendingPruneAt.map({ now >= $0 }) ?? true { + let retryDelay = maintenance() ? pendingPruneInterval : failedPruneRetryInterval + nextPendingPruneAt = now.advanced(by: retryDelay) + } + guard let result = await self?.collectCLIProxyAPIUsageNow(collector: collector) else { return } + collectorState = await self?.handleCLIProxyAPIUsageCollectionResult( + result, + collectorState: collectorState) ?? collectorState + do { + try await Task.sleep(for: collectionInterval) + } catch { + return + } + } + } + } + + @discardableResult + func stopCLIProxyAPIUsageCollector() -> Task? { + let task = self.cliProxyAPIUsageCollectorTask + task?.cancel() + self.cliProxyAPIUsageCollectorTask = nil + return task + } + + @discardableResult + func removeCLIProxyAPIConfiguration( + remove: (() async -> CLIProxyAPIConfigurationRemovalResult)? = nil, + scheduleCleanupRetry: (() -> Void)? = nil, + refresh: ((UsageProvider, Bool) async -> Void)? = nil) async + -> CLIProxyAPIConfigurationRemovalResult + { + let collectorTask = self.stopCLIProxyAPIUsageCollector() + self.cliProxyAPICleanupRetryTask?.cancel() + self.cliProxyAPICleanupRetryTask = nil + await collectorTask?.value + let result = if let remove { + await remove() + } else { + await Task.detached(priority: .utility) { + CLIProxyAPIConnectionSettingsStore.removeAndPurgeTelemetry() + }.value + } + if result != .configurationRemovalFailed { + self.invalidateCLIProxyAPICostAttribution(widgetReason: "cliproxyapi-disconnected") + await self.refreshCLIProxyAPIAffectedProviders(refresh: refresh) + } + 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 + { + guard self.settings.costUsageEnabled else { return .disabled } + if let collector { + return await collector() + } + return await CLIProxyAPIUsageCollector.collect(shouldContinue: { [weak self] in + await self?.settings.costUsageEnabled == true + }) + } + + func handleCLIProxyAPIUsageCollectionResult( + _ result: CLIProxyAPIUsageCollectionResult, + collectorState: CLIProxyAPIUsageCollectorState, + isExplicitlyDisconnected: () -> Bool = { + CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected() + }, + publishAttributionIsolation: (String?) async -> Bool = { expectedGeneration in + await CostUsageCacheLocations.publishCLIProxyAPIAttributionIsolationAsync( + expectedGeneration: expectedGeneration) + }, + configurationGeneration: () -> String? = { + CostUsageCacheLocations.cliProxyAPIConfigurationGeneration() + }, + telemetryRevision: () -> String? = { + CLIProxyAPIUsageTelemetryRevision.current() + }, + refresh: ((UsageProvider, Bool) async -> Void)? = nil) async -> CLIProxyAPIUsageCollectorState + { + 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 && + (explicitlyDisconnected || collectorState.configurationGeneration != nil)) || + collectorState.configurationTransitionPending + { + guard await publishAttributionIsolation(currentGeneration) else { + collectorState.configurationGeneration = currentGeneration + collectorState.telemetryRevision = telemetryRevision() + return collectorState + } + if !collectorState.configurationTransitionPending { + self.invalidateCLIProxyAPICostAttribution(widgetReason: "cliproxyapi-disconnected") + } + await self.refreshCLIProxyAPIAffectedProviders(refresh: refresh) + } + collectorState.configurationAvailability = .unavailable + 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 + { + await self.refreshCLIProxyAPICostAttribution(refresh: refresh) + } + collectorState.configurationAvailability = .available + collectorState.configurationGeneration = currentGeneration + collectorState.telemetryRevision = currentTelemetryRevision + collectorState.configurationTransitionPending = false + case .failed: + let currentGeneration = configurationGeneration() + let currentTelemetryRevision = telemetryRevision() + let configurationChanged = collectorState.configurationGeneration != nil && + collectorState.configurationGeneration != currentGeneration + if collectorState.telemetryRevision != currentTelemetryRevision { + await self.refreshCLIProxyAPICostAttribution(refresh: refresh) + } 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: + break + } + return collectorState + } + + func refreshCLIProxyAPICostAttribution( + refresh: ((UsageProvider, Bool) async -> Void)? = nil) async + { + self.invalidateCLIProxyAPICostAttribution(widgetReason: "cliproxyapi-reconnected") + 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) + } else { + await self.refreshTokenUsageNow(for: provider, force: true) + } + } + } +} diff --git a/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift b/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift index 9c0d2c89b3..bdb83138d8 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 } private enum CodexCostCatchUpPublicationError: LocalizedError { @@ -47,13 +48,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) + 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 @@ -62,6 +60,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 @@ -119,7 +125,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, @@ -130,7 +136,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, @@ -161,7 +167,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, @@ -191,7 +197,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, @@ -229,7 +235,7 @@ extension UsageStore { } } - guard self.codexCostCatchUpContextIsCurrent(context) else { return } + guard await self.codexCostCatchUpContextIsCurrent(context) else { return } guard didAdvance else { self.publishCodexCostCatchUpActivity( status: status, @@ -265,7 +271,7 @@ extension UsageStore { now: now, context: context) try Task.checkCancellation() - guard self.codexCostCatchUpContextIsCurrent(context), + guard await self.codexCostCatchUpContextIsCurrent(context), self.tokenSnapshotPublicationRevision(for: .codex) == publicationRevision else { throw CancellationError() @@ -316,7 +322,18 @@ extension UsageStore { .map { ($0.snapshot, $0.lastRefreshAt, $0.staleSnapshotUpdatedAt) } } - 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 diff --git a/Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift b/Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift index d9478281e6..1e386ed973 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 { @@ -84,13 +85,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) + let providerConfigRevision = self.settings.providerConfigRevision(for: .codex) + let costUsageSettingsRevision = self.settings.costUsageSettingsRevision self.spendDashboardCodexCostCatchUpToken = token self.spendDashboardCodexCostCatchUpScopeSignature = scopeSignature self.spendDashboardCodexCostCatchUpMode = mode @@ -99,6 +95,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 @@ -165,7 +169,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, @@ -177,7 +181,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, @@ -224,7 +228,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, @@ -263,7 +267,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, @@ -281,7 +285,7 @@ extension UsageStore { } catch is CancellationError { return } catch { - guard self.spendDashboardCodexCostCatchUpContextIsCurrent(context) else { return } + guard await self.spendDashboardCodexCostCatchUpContextIsCurrent(context) else { return } self.publishSpendDashboardCodexCostCatchUpActivity( statuses: statuses, context: context, @@ -298,6 +302,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 diff --git a/Sources/CodexBar/UsageStore+SpendDashboardTokenCost.swift b/Sources/CodexBar/UsageStore+SpendDashboardTokenCost.swift index 6575313c9f..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,6 +128,13 @@ extension UsageStore { return } + let cliProxyAPIAttributionGuard: CLIProxyAPIAttributionPublicationGuard? = if Self + .spendDashboardTokenUsageNeedsCLIProxyAPIAttributionGuard(provider) + { + await self.captureCLIProxyAPIAttributionPublicationGuard() + } else { + nil + } do { let snapshot = try await self.loadTokenUsageSnapshot( provider: provider, @@ -136,10 +150,11 @@ extension UsageStore { initialSignature: costScopeSignature, snapshot: snapshot, includeSettingsRevision: false) - guard self.spendDashboardTokenRefreshPublicationIsCurrent( + guard await self.spendDashboardTokenRefreshPublicationIsCurrent( provider: provider, publicationRevision: publicationRevision, providerConfigRevision: providerConfigRevision, + cliProxyAPIAttributionGuard: cliProxyAPIAttributionGuard, costScopeSignature: costScopeSignature, fetchedCredentialScopeFingerprint: snapshot.credentialScopeFingerprint) else { @@ -166,10 +181,11 @@ extension UsageStore { } self.publishSpendDashboardTokenSnapshot(snapshot, for: provider) } catch { - guard self.spendDashboardTokenRefreshPublicationIsCurrent( + guard await self.spendDashboardTokenRefreshPublicationIsCurrent( provider: provider, publicationRevision: publicationRevision, providerConfigRevision: providerConfigRevision, + cliProxyAPIAttributionGuard: cliProxyAPIAttributionGuard, costScopeSignature: costScopeSignature) else { self.clearSpendDashboardTokenFetchMetadataIfMatching( @@ -234,11 +250,18 @@ extension UsageStore { provider: UsageProvider, publicationRevision: ProviderPublicationRevision, providerConfigRevision: UInt64, + cliProxyAPIAttributionGuard: CLIProxyAPIAttributionPublicationGuard?, costScopeSignature: String, - fetchedCredentialScopeFingerprint: String? = nil) -> Bool + 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, + cliProxyAPIAttributionIsCurrent, self.settings.costUsageEnabled, self.isEnabled(provider) else { diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 076cec6589..804c8cf349 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -11,6 +11,20 @@ struct CurrentProviderConfigTokenPublication: Sendable, Equatable { let publicationRevision: UInt64 } +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 inputArtifactFingerprint: String? + let isIsolated: Bool +} + struct TokenSnapshotPublication: Sendable, Equatable { let snapshot: CostUsageTokenSnapshot? let publicationRevision: UInt64 @@ -145,6 +159,57 @@ extension UsageStore { } } + 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: await self.captureCLIProxyAPIAttributionPublicationGuard() + default: nil + } + return TokenRefreshPublicationGuard( + provider: self.providerPublicationRevision(for: provider), + tokenSnapshot: self.tokenSnapshotPublicationRevision(for: provider), + providerConfig: self.settings.providerConfigRevision(for: provider), + cliProxyAPIAttribution: cliProxyAPIAttribution) + } + + func cliProxyAPIAttributionPublicationGuard() -> CLIProxyAPIAttributionPublicationGuard { + CLIProxyAPIAttributionPublicationGuard( + configurationGeneration: self.costUsageFetcher.cliProxyAPIConfigurationGeneration(), + telemetryRevision: self.costUsageFetcher.cliProxyAPIUsageTelemetryRevision(), + inputArtifactFingerprint: self.costUsageFetcher.cliProxyAPIInputArtifactFingerprint(), + 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 + { + 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 + } + func retainsEstablishedTokenHistory(_ snapshot: CostUsageTokenSnapshot, for provider: UsageProvider) -> Bool { // A bounded Codex refresh can succeed with partial rows while catch-up remains pending. // Account and history-window changes fail the current-publication lookup below. @@ -170,6 +235,21 @@ extension UsageStore { self.publishTokenSnapshotState(nil, for: provider) } + func invalidateCLIProxyAPICostAttribution(widgetReason: String = "cliproxyapi-removed") { + self.cancelCodexCostCatchUp() + self.cancelSpendDashboardCodexCostCatchUp() + self.spendDashboardCodexCostCatchUpRevision &+= 1 + // Provider-specific by design: invalidating proxy attribution clears both derived provider projections. + for provider in [UsageProvider.codex, .claude] { + self.publishConfirmedEmptyTokenSnapshot(for: provider) + self.tokenErrors[provider.instanceID] = nil + self.tokenFailureGates[provider.instanceID]?.reset() + self.lastTokenFetchAt.removeValue(forKey: provider.instanceID) + self.lastTokenFetchScope.removeValue(forKey: provider.instanceID) + } + self.persistWidgetSnapshot(reason: widgetReason) + } + private func publishTokenSnapshotState(_ snapshot: CostUsageTokenSnapshot?, for provider: UsageProvider) { self.tokenSnapshotPublicationRevisions[provider.instanceID, default: 0] &+= 1 self.tokenSnapshotPublications[provider.instanceID] = TokenSnapshotPublication( @@ -195,6 +275,9 @@ extension UsageStore { } func clearTokenSnapshots() { + for provider in UsageProvider.allCases { + self.tokenSnapshotPublicationRevisions[provider.instanceID, default: 0] &+= 1 + } self.tokenSnapshots.removeAll() self.tokenSnapshotPublications.removeAll() self.spendDashboardTokenPublications.removeAll() @@ -267,6 +350,7 @@ extension UsageStore { let tokenSnapshotPublicationRevision = self.tokenSnapshotPublicationRevision(for: .codex) 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, @@ -300,6 +384,9 @@ extension UsageStore { self.settings.costUsageHistoryDays == historyDays, self.tokenSnapshotScopeSignature(for: .codex) == tokenSnapshotScopeSignature, self.tokenSnapshotPublicationRevision(for: .codex) == tokenSnapshotPublicationRevision, + await self.cliProxyAPIAttributionPublicationIsCurrentOffMain( + cliProxyAPIAttributionGuard, + for: .codex), self.tokenSnapshotPublicationForCurrentProviderConfig(for: .codex) == nil else { return @@ -444,14 +531,20 @@ extension UsageStore { func tokenRefreshPublicationIsCurrent( provider: UsageProvider, - publicationRevision: ProviderPublicationRevision, - providerConfigRevision: UInt64, + publicationGuard: TokenRefreshPublicationGuard, historyDays: Int, costScopeSignature: String, - fetchedCredentialScopeFingerprint: String? = nil) -> Bool + fetchedCredentialScopeFingerprint: String? = nil) async -> Bool { - guard self.providerPublicationRevisionIsCurrent(publicationRevision, for: provider), - self.settings.providerConfigRevision(for: provider) == providerConfigRevision, + 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, + cliProxyAPIAttributionIsCurrent, self.settings.isCostUsageEffectivelyEnabled(for: provider), self.isEnabled(provider), self.settings.costUsageHistoryDays == historyDays @@ -539,41 +632,63 @@ 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) - } - - func clearCostUsageCache() async -> String? { - let errorMessage: String? = await Task.detached(priority: .utility) { - let fm = FileManager.default - let cacheDirs = [ - Self.costUsageCacheDirectory(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 - } + CostUsageCacheLocations.directories(fileManager: fileManager)[0] + } + + func clearCostUsageCache( + clearDirectories: (@Sendable () async -> (cleared: Int, errorMessage: 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 { + if collectorTask != nil { + self.startCLIProxyAPIUsageCollector() } - return nil - }.value + } - guard errorMessage == nil else { return errorMessage } + await self.drainTokenRefreshesForCostCacheClear() + self.cancelCodexCostCatchUp() + self.cancelSpendDashboardCodexCostCatchUp() + + let cacheDirectories = CostUsageCacheLocations.directories(fileManager: fileManager) + let cliProxyAPIStateRoot = cacheDirectories[1].deletingLastPathComponent() + let clearResult: (didClear: Bool, errorMessage: String?) + if let clearDirectories { + let result = await clearDirectories() + clearResult = (result.errorMessage == nil || result.cleared > 0, result.errorMessage) + } else { + let result = await Task.detached(priority: .utility) { + CostUsageCacheLocations.clearAllCostUsageCaches( + in: cacheDirectories, + stateRoot: cliProxyAPIStateRoot) + }.value + clearResult = (result.errorDescription == nil || result.cleared > 0, result.errorDescription) + } + + guard clearResult.didClear else { return clearResult.errorMessage } self.clearTokenSnapshots() + self.spendDashboardCodexCostCatchUpRevision &+= 1 self.tokenErrors.removeAll() self.lastTokenFetchAt.removeAll() self.lastTokenFetchScope.removeAll() self.tokenFailureGates[.codex]?.reset() self.tokenFailureGates[.claude]?.reset() - return nil + return clearResult.errorMessage + } + + /// Fast failures may retry on the next scheduled pass instead of waiting out the fetch + /// TTL; timed-out scans keep the TTL so a slow corpus cannot thrash back-to-back rescans. + nonisolated static func tokenFetchFailureAllowsEarlyRetry(_ error: Error) -> Bool { + if case CostUsageError.timedOut = error { + return false + } + return true } nonisolated static func tokenCostNoDataMessage(for provider: UsageProvider) -> String { diff --git a/Sources/CodexBar/UsageStore+TokenRefreshSequence.swift b/Sources/CodexBar/UsageStore+TokenRefreshSequence.swift index ac7d11e8ea..360853e187 100644 --- a/Sources/CodexBar/UsageStore+TokenRefreshSequence.swift +++ b/Sources/CodexBar/UsageStore+TokenRefreshSequence.swift @@ -16,7 +16,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.startPendingForcedTokenRefreshIfPossible() { return } @@ -39,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 @@ -56,11 +60,13 @@ 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) } func refreshTokenUsageNow(for provider: UsageProvider, force: Bool) async { + guard !self.costUsageCacheClearInProgress else { return } if force, self.tokenRefreshSequenceTask != nil, let activeProvider = self.tokenRefreshSequenceProvider, @@ -79,10 +85,24 @@ extension UsageStore { await self.awaitTokenRefreshSequence(task) } + func drainTokenRefreshesForCostCacheClear() async { + self.pendingForcedTokenRefresh = false + self.tokenRefreshRetryProviders.removeAll() + let sequenceTask = self.tokenRefreshSequenceTask + sequenceTask?.cancel() + await sequenceTask?.value + while !self.tokenRefreshInFlight.isEmpty { + await Task.yield() + } + self.pendingForcedTokenRefresh = false + self.tokenRefreshRetryProviders.removeAll() + } + private func serializedTokenRefreshTask( force: Bool, scope: TokenRefreshSequenceScope) async -> Task? { + guard !self.costUsageCacheClearInProgress else { return nil } if force { while let existing = self.tokenRefreshSequenceTask { existing.cancel() @@ -154,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. @@ -164,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() @@ -173,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/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index f17ae012fa..f5e0b8e299 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -199,6 +199,7 @@ final class UsageStore { @ObservationIgnored var sharedSpendDashboardTokenPublicationDebounceTask: Task? var tokenErrors: [ProviderInstanceID: String] = [:] var tokenRefreshInFlight: Set = [] + @ObservationIgnored var costUsageCacheClearInProgress = false var codexCostCatchUpActivity: CodexCostCatchUpActivity? var spendDashboardCodexCostCatchUpActivity: CodexCostCatchUpActivity? var spendDashboardCodexCostCatchUpRevision: UInt64 = 0 @@ -441,6 +442,8 @@ 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 @@ -548,6 +551,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.startSharedSpendDashboardPublication() self.detectVersions() @@ -565,6 +569,8 @@ final class UsageStore { } Task { await self.refresh(enrichmentMode: .automatic) } self.startTimer() + self.startCLIProxyAPIUsageCollector( + initialConfigurationGeneration: cliProxyAPIConfigurationGeneration) } var iconStyle: IconStyle { @@ -967,6 +973,8 @@ final class UsageStore { self.codexPlanHistoryBackfillTask?.cancel() self.resetBoundaryRefreshTask?.cancel() self.planUtilizationHistoryLoadTask?.cancel() + self.cliProxyAPIUsageCollectorTask?.cancel() + self.cliProxyAPICleanupRetryTask?.cancel() } enum SessionQuotaWindowSource: String { @@ -1495,8 +1503,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 = await self.tokenRefreshPublicationGuard(for: provider) if !force, self.tokenRefreshCanReuseCurrentSnapshot( provider: provider, now: now, @@ -1538,10 +1545,9 @@ extension UsageStore { historyDays: historyDays, initialSignature: costScopeSignature, snapshot: snapshot) - guard self.tokenRefreshPublicationIsCurrent( + guard await self.tokenRefreshPublicationIsCurrent( provider: provider, - publicationRevision: publicationRevision, - providerConfigRevision: providerConfigRevision, + publicationGuard: publicationGuard, historyDays: historyDays, costScopeSignature: costScopeSignature, fetchedCredentialScopeFingerprint: snapshot.credentialScopeFingerprint) @@ -1572,10 +1578,9 @@ extension UsageStore { self.tokenFailureGates[provider.instanceID]?.recordSuccess() self.persistWidgetSnapshot(reason: "token-usage") } catch { - guard self.tokenRefreshPublicationIsCurrent( + guard await self.tokenRefreshPublicationIsCurrent( provider: provider, - publicationRevision: publicationRevision, - providerConfigRevision: providerConfigRevision, + publicationGuard: publicationGuard, historyDays: historyDays, costScopeSignature: costScopeSignature) else { @@ -1645,15 +1650,6 @@ extension UsageStore { self.lastTokenFetchAt.removeValue(forKey: provider.instanceID) self.lastTokenFetchScope.removeValue(forKey: provider.instanceID) } - - /// Fast failures may retry on the next scheduled pass instead of waiting out the fetch - /// TTL; timed-out scans keep the TTL so a slow corpus cannot thrash back-to-back rescans. - nonisolated static func tokenFetchFailureAllowsEarlyRetry(_ error: Error) -> Bool { - if case CostUsageError.timedOut = error { - return false - } - return true - } } extension UsageStore { diff --git a/Sources/CodexBarCLI/CLICacheCommand.swift b/Sources/CodexBarCLI/CLICacheCommand.swift index 78d63e96ec..e06574c306 100644 --- a/Sources/CodexBarCLI/CLICacheCommand.swift +++ b/Sources/CodexBarCLI/CLICacheCommand.swift @@ -53,19 +53,12 @@ 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) { - do { - try fm.removeItem(at: cacheDir) - cleared = 1 - } catch { - costError = error.localizedDescription - } - } - 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 { @@ -144,9 +137,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/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/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift new file mode 100644 index 0000000000..833714eabd --- /dev/null +++ b/Sources/CodexBarCore/CLIProxyAPIAttributionResolver.swift @@ -0,0 +1,1079 @@ +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 { + let provider: String + let authType: CostUsageAttribution.Upstream.AuthType + } + + struct TokenSignature: Sendable, Equatable { + let input: Int + let cacheRead: Int + let cacheCreate: Int + let output: Int + } + + struct Request: Sendable { + let model: String + let modelProvider: CostUsageAttribution.ModelProvider + 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 { + let sourceID: String? + 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 + 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]] + private let observationsByCanonicalModel: [String: [Observation]] + private let usageRecordsByCanonicalModel: [String: [IndexedUsageRecord]] + 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, + inputArtifactFingerprint: [String: CostUsageClaudeFileStamp] = [:]) + { + 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.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 + self.inputArtifactFingerprint = inputArtifactFingerprint + } + + static func load( + home: URL, + cacheRoot: URL? = nil, + fileManager: FileManager = .default, + forceReload: Bool = false, + 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) + 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), + inputArtifactFingerprint: inputArtifactFingerprint) + guard try inputArtifactFingerprint == self.inputArtifactFingerprint( + home: home, + fileManager: fileManager, + checkCancellation: checkCancellation) + else { throw CancellationError() } + return resolver + } + + func attribution( + model: String, + modelProvider: CostUsageAttribution.ModelProvider, + sessionID: String?, + timestampUnixMs: Int64?, + tokens: TokenSignature?) -> CostUsageAttribution + { + let request = Request( + model: model, + modelProvider: modelProvider, + sessionID: sessionID, + timestampUnixMs: timestampUnixMs, + tokens: tokens) + let routeObservation = self.matchingObservation( + model: model, + sessionID: sessionID, + timestampUnixMs: timestampUnixMs) + let telemetryObservation = self.matchingObservation( + model: model, + sessionID: sessionID, + timestampUnixMs: timestampUnixMs) + let usageRecord = telemetryObservation.flatMap { + self.matchingUsageRecord( + observation: $0, + model: model, + tokens: tokens) + } + return self.attribution( + request: request, + routeObservation: routeObservation, + usageRecord: usageRecord) + } + + func attributions(for requests: [Request]) -> [CostUsageAttribution] { + var prepared = requests.map { request in + let observationCandidates = self.matchingObservations( + model: request.model, + sessionID: request.sessionID, + timestampUnixMs: request.timestampUnixMs) + let observationMatches = observationCandidates.compactMap { observation in + self.closestUsageRecordMatch( + observation: observation, + model: request.model, + tokens: request.tokens) + } + let matchKeys = Set(observationMatches.map(\.key)) + 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, + 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)]] = [:] + 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 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, + let candidate = candidates.first, + prepared[candidate.index].usageRecordMatch != nil + || Self.isCloseRouteMatch(candidate) + { + routeOwnerByObservation[key] = candidate.index + } else if candidates.count > 1, + let observationTimestamp = candidates.first?.observation.timestamp, + let candidate = Self.uniqueClosest( + candidates, + target: observationTimestamp, + timestamp: { Self.timestamp(for: $0.request) }), + prepared[candidate.index].usageRecordMatch.map({ matchCounts[$0.key] == 1 }) == true + || Self.isCloseRouteMatch(candidate) + { + routeOwnerByObservation[key] = candidate.index + } + } + 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.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( + for: match.record, + model: item.request.model, + representedObservations: representedObservations) + else { return nil } + return match.record + } + return self.attribution( + request: item.request, + routeObservation: routeObservation, + usageRecord: usageRecord) + } + } + + 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, + sessionID: request.sessionID, + timestampUnixMs: request.timestampUnixMs) != nil + } + + private func attribution( + request: Request, + routeObservation: Observation?, + usageRecord: CLIProxyAPIUsageRecord?) -> CostUsageAttribution + { + 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 && routeObserved + ? .openAI + : request.modelProvider + let inventoryUpstream = usageRecord == nil + ? self.authInventoryUpstream( + model: request.model, + modelProvider: resolvedModelProvider, + routeObserved: routeObservation != nil, + configuredCodexModel: configuredCodexModel) + : nil + let routeConfirmed = routeObservation != nil || usageRecord != nil || inventoryUpstream != nil + var evidence: Set = [.modelProvider] + if routeObservation != nil { + evidence.insert(.cliProxyRequestLog) + } + if configuredCodexModel != nil, inventoryUpstream != nil { + evidence.insert(.cliProxyModelAlias) + } + if usageRecord != nil { + evidence.insert(.cliProxyUsageTelemetry) + } + if inventoryUpstream != nil { + evidence.insert(.cliProxyAuthInventory) + } + + return CostUsageAttribution( + client: .claudeCode, + route: routeConfirmed ? .cliProxyAPI : .unknown, + modelProvider: resolvedModelProvider, + upstream: usageRecord.map(Self.upstream) ?? inventoryUpstream, + evidence: evidence.sorted { $0.rawValue < $1.rawValue }) + } + + private func matchingObservation( + 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 [] } + + let canonicalModel = Self.canonicalModel(model) + let matchingModels = observations.filter { Self.canonicalModel($0.model) == canonicalModel } + guard !matchingModels.isEmpty else { return [] } + guard let timestampUnixMs else { + return matchingModels.count == 1 ? matchingModels : [] + } + let timestamp = Date(timeIntervalSince1970: Double(timestampUnixMs) / 1000) + 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 + } + 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? { + request.timestampUnixMs.map { + Date(timeIntervalSince1970: Double($0) / 1000) + } + } + + private func matchingUsageRecord( + 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 plausibleObservations( + for record: CLIProxyAPIUsageRecord, + model: String) -> [Observation] + { + let canonicalModel = Self.canonicalModel(model) + return self.observationsByCanonicalModel[canonicalModel]?.filter { + guard let timestamp = $0.timestamp else { return false } + return abs(timestamp.timeIntervalSince(record.timestamp)) <= Self.maximumTelemetryMatchDistance + } ?? [] + } + + private func allPlausibleObservationsRepresented( + for record: CLIProxyAPIUsageRecord, + model: String, + representedObservations: Set) -> Bool + { + let plausibleKeys = self.plausibleObservations(for: record, model: model).map(Self.observationKey) + guard !plausibleKeys.isEmpty else { return true } + return Set(plausibleKeys).count == plausibleKeys.count + && plausibleKeys.allSatisfy(representedObservations.contains) + } + + private static func observationKey(_ observation: Observation) -> ObservationKey { + ObservationKey( + sourceID: observation.sourceID, + sessionID: observation.sessionID, + canonicalModel: self.canonicalModel(observation.model), + timestamp: observation.timestamp) + } + + private static func indexUsageRecords( + _ records: [CLIProxyAPIUsageRecord]) -> [String: [IndexedUsageRecord]] + { + var recordsByModel: [String: [IndexedUsageRecord]] = [:] + for (sourceID, record) in records.enumerated() where !record.failed + && record.generate + && self.isClaudeMessagesGenerationEndpoint(record.endpoint) + { + let models = Set([self.canonicalModel(record.alias), self.canonicalModel(record.model)]) + for model in models where !model.isEmpty { + recordsByModel[model, default: []].append(IndexedUsageRecord( + sourceID: sourceID, + record: record)) + } + } + return recordsByModel.mapValues { records in + records.sorted { $0.record.timestamp < $1.record.timestamp } + } + } + + private static func firstRecordIndex( + atOrAfter timestamp: Date, + in records: [IndexedUsageRecord]) -> Int + { + var lowerBound = 0 + var upperBound = records.count + while lowerBound < upperBound { + let midpoint = lowerBound + (upperBound - lowerBound) / 2 + if records[midpoint].record.timestamp < timestamp { + lowerBound = midpoint + 1 + } else { + upperBound = midpoint + } + } + return lowerBound + } + + private func authInventoryUpstream( + model: String, + modelProvider: CostUsageAttribution.ModelProvider, + routeObserved: Bool, + configuredCodexModel: String?) -> CostUsageAttribution.Upstream? + { + let providers = Array(Set(self.authProviders)) + // Provider-specific by design: CLIProxyAPI's auth inventory names its Codex OAuth backend with a raw string. + let codexProviders = providers.filter { + $0.provider.caseInsensitiveCompare("codex") == .orderedSame + } + if let configuredCodexModel { + guard routeObserved, + 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-specific by design: only CLIProxyAPI's Codex OAuth backend proves subscription attribution. + provider.provider.caseInsensitiveCompare("codex") == .orderedSame + else { return nil } + return CostUsageAttribution.Upstream( + provider: provider.provider, + authType: provider.authType, + model: model.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + 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 nil } + return (candidate, abs(date.timeIntervalSince(target))) + } + .sorted { $0.distance < $1.distance } + 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 + } + + 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 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] + { + 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.isEmpty + else { return nil } + // Provider-specific by design: CLIProxyAPI serializes Codex OAuth credentials under the raw type name. + let isCodex = rawType.caseInsensitiveCompare("codex") == .orderedSame + return AuthProvider( + provider: isCodex ? "codex" : rawType.lowercased(), + authType: isCodex ? .oauth : .unknown) + } + return Array(Set(providers)) + } + + static func parseCodexOAuthModelAliases(_ text: String) -> [String: String] { + var aliases: [String: String] = [:] + 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) + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty, !trimmed.hasPrefix("#") else { continue } + let structureKey = self.simpleYAMLMappingKey(trimmed) + let indent = line.prefix { $0 == " " }.count + + if rootIndent == nil { + if structureKey == "oauth-model-alias" { + rootIndent = indent + } + continue + } + + guard let rootIndent else { continue } + if indent <= rootIndent { + break + } + if codexIndent == nil { + // Provider-specific by design: this parser reads only CLIProxyAPI's Codex OAuth alias section. + if structureKey == "codex" { + codexIndent = indent + } + continue + } + + guard let codexIndent else { continue } + if indent <= codexIndent { + break + } + + let startsItem = trimmed == "-" || 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 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))) + } + } + if let currentName, let currentAlias, !currentName.isEmpty, !currentAlias.isEmpty { + aliases[currentAlias] = currentName + } + return aliases + } + + static func hasCodexOAuthModelAliasRoute( + home: URL, + fileManager: FileManager = .default) -> Bool + { + guard !self.loadCodexOAuthModelAliases(home: home, fileManager: fileManager).isEmpty else { + return false + } + // Provider-specific by design: a Codex auth inventory entry is required to activate Codex model aliases. + 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 + { + 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 conflictingKeys.contains { self.topLevelYAMLSequenceHasEntries($0, in: text) } + } + + private static func loadObservations( + logDirectory: URL, + fileManager: FileManager, + forceReload: Bool, + checkCancellation: (() throws -> Void)?) throws -> [Observation] + { + try self.observationCache.load( + logDirectory: logDirectory, + fileManager: fileManager, + forceReload: forceReload, + checkCancellation: checkCancellation) + { url in + self.parseObservation(url: url) + } + } + + 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[.. 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) { + 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() + } +} + +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 } + + 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) + for (index, line) in lines.enumerated() { + guard line.first?.isWhitespace != true else { continue } + let structure = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard self.simpleYAMLMappingKey(structure) == key, + let separator = self.firstUnquotedColon(in: structure) + else { continue } + + let inlineValue = self.simpleYAMLScalar(String(structure[structure.index(after: separator)...])) + 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, + 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 } + 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 + || telemetry.input == claudeInputTotal + || (!telemetryInputTotalOverflow && telemetryInputTotal == claudeInputTotal) + } + + 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 new file mode 100644 index 0000000000..4d69d24d8a --- /dev/null +++ b/Sources/CodexBarCore/CLIProxyAPIUsageTelemetry.swift @@ -0,0 +1,1475 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +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 localOccurrenceID: 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 localOccurrenceID = "codexbar_occurrence_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, + localOccurrenceID: String? = nil, + 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.localOccurrenceID = localOccurrenceID + 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.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) + } +} + +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 + } + } +} + +enum CLIProxyAPIUsageCacheIO { + private struct Cache: Codable, Equatable { + var version: Int = 1 + var records: [CLIProxyAPIUsageRecord] = [] + } + + private enum CacheReadResult { + case missing + case valid(Cache) + case invalid + } + + private static let cacheLock = NSLock() + + static func withExclusiveAccess(_ body: () throws -> T) rethrows -> T { + 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 retainedCache = Cache(records: CLIProxyAPIUsageRetention.normalize(currentCache.records, now: now)) + return retainedCache == currentCache || self.save(retainedCache, cacheRoot: cacheRoot) + } + } + + 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) + } + + /// 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 + return self.withExclusiveAccess { + guard let currentCache = self.loadCache( + cacheRoot: cacheRoot, + legacyCacheRoot: legacyCacheRoot) + else { return [] } + let retainedRecords = CLIProxyAPIUsageRetention.normalize(currentCache.records, now: now) + let retainedCache = Cache(records: retainedRecords) + if retainedCache != currentCache { + _ = self.save(retainedCache, cacheRoot: cacheRoot) + } + return retainedRecords + } + } + + static func load( + cacheRoot: URL?, + legacyCacheRoot: URL?, + now: Date = Date()) -> [CLIProxyAPIUsageRecord] + { + if self.hasLegacyCacheToMigrate( + cacheRoot: cacheRoot, + legacyCacheRoot: legacyCacheRoot) + { + do { + return try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( + stateRoot: cacheRoot) + { + self.withExclusiveAccess { + guard let currentCache = self.loadCache( + cacheRoot: cacheRoot, + legacyCacheRoot: legacyCacheRoot) + else { return [] } + let retainedRecords = CLIProxyAPIUsageRetention.normalize(currentCache.records, now: now) + 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).map { CLIProxyAPIUsageRetention.normalize($0.records, now: now) } ?? [] + } + } + } + + let initialSnapshot: (records: [CLIProxyAPIUsageRecord], needsPruning: Bool) = self.withExclusiveAccess { + guard let existingCache = self.loadCache( + cacheRoot: cacheRoot, + legacyCacheRoot: nil) + else { return ([], false) } + let retainedRecords = CLIProxyAPIUsageRetention.normalize(existingCache.records, now: now) + return (retainedRecords, retainedRecords != existingCache.records) + } + guard initialSnapshot.needsPruning else { return initialSnapshot.records } + + do { + return try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( + stateRoot: cacheRoot) + { + self.withExclusiveAccess { + guard let currentCache = self.loadCache( + cacheRoot: cacheRoot, + legacyCacheRoot: nil) + else { return [] } + let retainedRecords = CLIProxyAPIUsageRetention.normalize(currentCache.records, now: now) + let retainedCache = Cache(records: retainedRecords) + if retainedCache != currentCache { + _ = self.save(retainedCache, cacheRoot: cacheRoot) + } + return retainedRecords + } + } + } catch { + return initialSnapshot.records + } + } + + @discardableResult + static func merge( + _ 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? + { + self.withExclusiveAccess { + guard let existingCache = self.loadCache( + cacheRoot: cacheRoot, + legacyCacheRoot: legacyCacheRoot) + else { return nil } + var byKey = self.recordsByKey(CLIProxyAPIUsageRetention.normalize(existingCache.records, now: now)) + let priorCount = byKey.count + 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 }) + if cache == existingCache { + return 0 + } + guard self.save(cache, cacheRoot: cacheRoot) else { return nil } + return max(0, byKey.count - priorCount) + } + } + + static func cacheFileURL(cacheRoot: URL? = nil) -> URL { + let root = cacheRoot ?? FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask).first! + .appendingPathComponent("CodexBar", isDirectory: true) + return root + .appendingPathComponent("cost-usage", isDirectory: true) + .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 + .appendingPathComponent("cost-usage", isDirectory: true) + .appendingPathComponent(CostUsageCacheLocations.cliProxyAPIUsageFileName, 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 = .custom { date, encoder in + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + var container = encoder.singleValueContainer() + try container.encode(formatter.string(from: date)) + } + 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 localOccurrenceID = record.localOccurrenceID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !localOccurrenceID.isEmpty { + return "occurrence:\(localOccurrenceID)" + } + 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 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) + let localOccurrenceID = record.localOccurrenceID? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !requestID.isEmpty || !localOccurrenceID.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) + } + + 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? + 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 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) { + try? FileManager.default.removeItem(at: legacyURL) + } + return migratedCache + } + + 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 .invalid } + return .valid(cache) + } + + private static func mergedCaches(legacy: Cache, durable: Cache?) -> Cache { + 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 }) + } + + 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 + } + } +} + +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 + var records: [CLIProxyAPIUsageRecord] = [] + } + + 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 } + let retainedRecords = CLIProxyAPIUsageRetention.normalize(pendingBatch.records, now: now) + if retainedRecords != pendingBatch.records { + 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 { + 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(CostUsageCacheLocations.cliProxyAPIPendingFileName, 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 = .custom { date, encoder in + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + var container = encoder.singleValueContainer() + try container.encode(formatter.string(from: date)) + } + encoder.outputFormatting = [.sortedKeys] + return encoder + }() +} + +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(), + ["127.0.0.1", "::1"].contains(host) + else { return nil } + return url + } +} + +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 isDisconnected: @Sendable () -> Bool + let loadStored: @Sendable () -> StoredSettingsSnapshot + let store: @Sendable (CLIProxyAPIConnectionSettings) -> 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") + + public static func load() -> CLIProxyAPIConnectionSettings? { + guard case let .found(settings) = self.loadResult() else { return nil } + return settings + } + + public static func loadResult() -> KeychainCacheStore.LoadResult { + self.loadResult( + isDisconnected: { CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected() }, + 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( + isDisconnected: () -> Bool, + loadStored: () -> CLIProxyAPIConnectionSettings?) -> CLIProxyAPIConnectionSettings? + { + guard !isDisconnected() else { return nil } + return loadStored() + } + + @discardableResult + public static func save(_ settings: CLIProxyAPIConnectionSettings) -> Bool { + let fileManager = FileManager.default + let directories = CostUsageCacheLocations.directories(fileManager: fileManager) + return self.saveSerialized( + settings, + artifactDirectories: directories, + stateRoot: directories[1].deletingLastPathComponent(), + fileManager: fileManager, + operations: SerializedSaveOperations( + isDisconnected: { CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected() }, + loadStored: { + self.storedSettingsSnapshot(from: KeychainCacheStore.load( + key: self.key, + as: CLIProxyAPIConnectionSettings.self)) + }, + store: { KeychainCacheStore.storeResult(key: self.key, entry: $0) }, + setDisconnectedState: { CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected($0) }, + restore: { storedSettings in + self.restoreStoredSettings( + storedSettings, + store: { KeychainCacheStore.storeResult(key: self.key, entry: $0) }, + clear: { KeychainCacheStore.clearResult(key: self.key) }) + })) + } + + static func storedSettingsSnapshot( + from result: KeychainCacheStore.LoadResult) -> StoredSettingsSnapshot + { + switch result { + case let .found(settings): .found(settings) + case .missing: .missing + case .interactionRequired, .temporarilyUnavailable, .invalid: .unavailable + } + } + + 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 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 .interactionRequired, .temporarilyUnavailable, .invalid: + nil + } + } + + 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 .interactionRequired, .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, + storedSettings: StoredSettingsSnapshot) -> ArtifactDisposition? + { + switch storedSettings { + case let .found(currentSettings): + if !isDisconnected, currentSettings == settings { + return .preserve + } + return .purge + case .missing: + return .purge + case .unavailable: + return nil + } + } + + static func saveSerialized( + _ settings: CLIProxyAPIConnectionSettings, + artifactDirectories: [URL] = [], + stateRoot: URL?, + fileManager: FileManager, + 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, + fileManager: fileManager) + { + 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, + storedSettings: storedSettings) + else { return false } + guard let generationUpdate = CostUsageCacheLocations + .prepareCLIProxyAPIConfigurationGenerationUpdate( + stateRoot: stateRoot, + fileManager: fileManager) + else { return false } + defer { + CostUsageCacheLocations.discardCLIProxyAPIConfigurationGenerationUpdate( + generationUpdate, + fileManager: fileManager) + } + let artifactsUpdate: CostUsageCacheLocations.CLIProxyAPIArtifactsUpdate? + switch artifactDisposition { + case .preserve: + artifactsUpdate = nil + case .purge: + guard let update = CostUsageCacheLocations.prepareCLIProxyAPIArtifactsUpdate( + in: artifactDirectories, + stateRoot: stateRoot, + expectedGeneration: generationUpdate.generation, + fileManager: fileManager, + disconnectedStateAfterCommit: false, + disconnectedStateAfterRollback: wasDisconnected, + replacementCredentialFingerprint: replacementCredentialFingerprint, + replacementCredentialsStored: false, + rollbackCredentialFingerprint: rollbackCredentialProvenance.fingerprint, + rollbackCredentialWasMissing: rollbackCredentialProvenance.wasMissing, + prepareState: { operations.setDisconnectedState(true) }) + else { + _ = operations.setDisconnectedState(wasDisconnected) + return false + } + artifactsUpdate = update + } + + func rollback(credentialsMayHaveChanged: Bool) { + if let artifactsUpdate { + guard CostUsageCacheLocations.markCLIProxyAPIArtifactsUpdateForRollback( + artifactsUpdate, + rollbackCredentialsRestored: !credentialsMayHaveChanged, + 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 { + _ = CostUsageCacheLocations.restoreCLIProxyAPIArtifactsUpdate( + artifactsUpdate, + fileManager: fileManager) + } + } + + guard CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate( + generationUpdate, + fileManager: fileManager) + else { + 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(credentialsMayHaveChanged: true) + return false + } + if let artifactsUpdate, + !CostUsageCacheLocations.markCLIProxyAPIArtifactsReplacementCredentialsStored( + artifactsUpdate, + fileManager: fileManager) + { + rollback(credentialsMayHaveChanged: true) + return false + } + guard operations.setDisconnectedState(false) else { + rollback(credentialsMayHaveChanged: true) + return false + } + if let artifactsUpdate { + CostUsageCacheLocations.discardCLIProxyAPIArtifactsUpdate( + artifactsUpdate, + fileManager: fileManager) + } + return true + } + } catch { + return false + } + } + + @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, + 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 + self.restoreStoredSettings( + storedSettings, + store: { KeychainCacheStore.storeResult(key: self.key, entry: $0) }, + clear: { KeychainCacheStore.clearResult(key: self.key) }) + })) + } + + static func removeAndPurgeTelemetry( + in directories: [URL], + stateRoot: URL?, + fileManager: FileManager, + operations: SerializedRemovalOperations) -> CLIProxyAPIConfigurationRemovalResult + { + do { + return try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( + stateRoot: stateRoot, + fileManager: fileManager) + { + let wasDisconnected = operations.isDisconnected() + let storedSettings = operations.loadStored() + if case .unavailable = storedSettings { return .configurationRemovalFailed } + return self.removeAndPurgeTelemetryUnserialized( + in: directories, + 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 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 } + + func rollback(credentialsMayHaveChanged: Bool) { + guard CostUsageCacheLocations.markCLIProxyAPIArtifactsUpdateForRollback( + artifactsUpdate, + 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) + } + + guard CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate( + generationUpdate, + fileManager: fileManager) + else { + rollback(credentialsMayHaveChanged: false) + return .configurationRemovalFailed + } + guard operations.setDisconnectedState(true), + CostUsageCacheLocations.markCLIProxyAPIArtifactsRemovalIsolationPublished( + artifactsUpdate, + fileManager: fileManager) + else { + 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(credentialsMayHaveChanged: true) + return .configurationRemovalFailed + } + guard CostUsageCacheLocations.markCLIProxyAPIArtifactsRemovalCredentialsCleared( + artifactsUpdate, + fileManager: fileManager) + else { + rollback(credentialsMayHaveChanged: true) + return .configurationRemovalFailed + } + return CostUsageCacheLocations.discardCLIProxyAPIArtifactsUpdate( + artifactsUpdate, + 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() }, + 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 + } + + switch clearConfiguration() { + case .removed, .missing: + return true + case .failed: + if !wasDisconnected { + _ = setDisconnectedState(false) + } + return false + } + } +} + +public enum CLIProxyAPIConfigurationRemovalResult: Equatable, Sendable { + case removed + case configurationRemovalFailed + case telemetryCleanupFailed +} + +public enum CLIProxyAPIUsageCollectionResult: Equatable, Sendable { + case disabled + case notConfigured + case collected(Int) + 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() + + @discardableResult + public static func pruneExpiredUsage(now: Date = Date()) -> Bool { + self.pruneExpiredUsage( + cacheRoot: nil, + pendingRoot: nil, + stateRoot: nil, + now: now) + } + + @discardableResult + static func pruneExpiredUsage( + cacheRoot: URL?, + pendingRoot: URL?, + stateRoot: URL?, + now: Date, + fileManager: FileManager = .default) -> Bool + { + do { + return try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( + stateRoot: stateRoot, + fileManager: fileManager) + { + let pendingPruned = CLIProxyAPIUsagePendingIO.load(pendingRoot: pendingRoot, now: now) != nil + let durablePruned = CLIProxyAPIUsageCacheIO.pruneUnserialized(cacheRoot: cacheRoot, now: now) + return pendingPruned && durablePruned + } + } catch { + return false + } + } + + public static func collect( + cacheRoot: URL? = nil, + 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 .interactionRequired, .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 + { + 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 + let configurationGeneration = CostUsageCacheLocations.cliProxyAPIConfigurationGeneration(stateRoot: stateRoot) + return await self.collect( + cacheRoot: cacheRoot, + configurationIsCurrent: { + guard !CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected(stateRoot: stateRoot) + else { return false } + return switch currentSettingsResult() { + case let .found(currentSettings): currentSettings == settings + case .interactionRequired, .temporarilyUnavailable: + CostUsageCacheLocations.cliProxyAPIConfigurationGeneration(stateRoot: stateRoot) == + configurationGeneration + case .missing, .invalid: false + } + }, + shouldContinue: shouldContinue, + client: client ?? CLIProxyAPIUsageQueueClient(settings: settings)) + } + + static func collect( + cacheRoot: URL? = nil, + pendingRoot: URL? = nil, + configurationIsCurrent: @escaping @Sendable () -> Bool = { true }, + shouldContinue: @escaping @Sendable () async -> Bool = { true }, + client: CLIProxyAPIUsageQueueClient) async -> CLIProxyAPIUsageCollectionResult + { + guard configurationIsCurrent() else { return .notConfigured } + return await self.collectionGate.perform { + do { + return try await CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( + stateRoot: cacheRoot) + { + guard configurationIsCurrent() else { return .notConfigured } + return await self.collectUnserialized( + cacheRoot: cacheRoot, + pendingRoot: pendingRoot, + configurationIsCurrent: configurationIsCurrent, + shouldContinue: shouldContinue, + client: client) + } + } catch { + return .failed("Could not lock CLIProxyAPI usage telemetry: \(error.localizedDescription)") + } + } + } + + private static func collectUnserialized( + cacheRoot: URL?, + pendingRoot: URL?, + configurationIsCurrent: @escaping @Sendable () -> Bool, + shouldContinue: @escaping @Sendable () async -> Bool, + client: CLIProxyAPIUsageQueueClient) async -> CLIProxyAPIUsageCollectionResult + { + do { + var added = 0 + let effectivePendingRoot = pendingRoot ?? cacheRoot + guard let pendingRecords = CLIProxyAPIUsagePendingIO.load(pendingRoot: effectivePendingRoot) else { + return .failed("Could not load pending CLIProxyAPI usage telemetry.") + } + if !pendingRecords.isEmpty { + guard let pendingAdded = CLIProxyAPIUsageCacheIO.merge( + pendingRecords, + cacheRoot: cacheRoot) + else { + return .failed("Could not save CLIProxyAPI usage telemetry.") + } + added += pendingAdded + guard CLIProxyAPIUsagePendingIO.clear(pendingRoot: effectivePendingRoot) else { + return .failed("Could not clear pending CLIProxyAPI usage telemetry.") + } + } + + 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 + 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 -> PoppedBatch { + 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 + } + 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) { + try await self.liveURLSession().data(for: request) + } + + static func liveURLSession() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 5 + configuration.timeoutIntervalForResource = 10 + return ProviderHTTPClient.redirectGuardedSession(configuration: configuration) + } +} diff --git a/Sources/CodexBarCore/CostUsageCacheLocations.swift b/Sources/CodexBarCore/CostUsageCacheLocations.swift new file mode 100644 index 0000000000..be53e51cf3 --- /dev/null +++ b/Sources/CodexBarCore/CostUsageCacheLocations.swift @@ -0,0 +1,912 @@ +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? +} + +// swiftlint:disable:next type_body_length +public enum CostUsageCacheLocations { + struct CLIProxyAPIArtifactsUpdate: Sendable { + struct Move: Sendable { + let originalURL: URL + let stagedURL: URL + } + + 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] + let disconnectedStateAfterCommit: Bool? + let disconnectedStateAfterRollback: Bool? + let replacementCredentialFingerprint: String? + let replacementCredentialsStored: Bool? + let rollbackCredentialFingerprint: String? + let rollbackCredentialWasMissing: Bool? + let forceRollback: Bool? + let rollbackCredentialsRestored: Bool? + let removalIsolationPublished: Bool? + let removalCredentialsCleared: Bool? + } + + 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! + 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) + } + } + + public static func clearAllCostUsageCaches( + fileManager: FileManager = .default) -> CostUsageCacheClearResult + { + self.clearAllCostUsageCaches( + in: self.directories(fileManager: fileManager), + stateRoot: nil, + fileManager: fileManager) + } + + public static func clearAllCostUsageCaches( + in directories: [URL], + stateRoot: URL?, + fileManager: FileManager = .default) -> CostUsageCacheClearResult + { + do { + return try self.withCLIProxyAPIInterprocessLock( + 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 { + 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, + recoverRemovalConfiguration: (() -> Bool)? = nil, + recoverReplacementConfiguration: ((String) -> 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, + recoverReplacementConfiguration: recoverReplacementConfiguration, + recoverRemovalConfiguration: recoverRemovalConfiguration ?? { + CLIProxyAPIConnectionSettingsStore.recoverInterruptedRemovalUnserialized( + stateRoot: stateRoot, + fileManager: fileManager) + }) + else { + throw CocoaError(.fileReadUnknown) + } + return try operation() + } + + static func withCLIProxyAPIInterprocessLock( + 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) + defer { self.releaseCLIProxyAPILock(descriptor) } + guard self.recoverCLIProxyAPIArtifactsTransaction( + stateRoot: stateRoot, + fileManager: fileManager, + recoverReplacementConfiguration: recoverReplacementConfiguration, + recoverRemovalConfiguration: recoverRemovalConfiguration ?? { + CLIProxyAPIConnectionSettingsStore.recoverInterruptedRemovalUnserialized( + stateRoot: stateRoot, + fileManager: fileManager) + }) + else { + throw CocoaError(.fileReadUnknown) + } + return try await operation() + } + + @discardableResult + public static func clearCLIProxyAPIArtifacts(fileManager: FileManager = .default) -> Bool { + 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) + { + guard self.advanceCLIProxyAPIConfigurationGeneration( + stateRoot: stateRoot, + fileManager: fileManager) + else { return false } + return self.clearCLIProxyAPIArtifactsUnserialized( + in: directories, + fileManager: fileManager) + } + } catch { + return false + } + } + + static func clearCLIProxyAPIArtifactsUnserialized( + in directories: [URL], + fileManager: FileManager) -> Bool + { + var succeeded = true + 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], + stateRoot: URL?, + expectedGeneration: String, + fileManager: FileManager, + disconnectedStateAfterCommit: Bool? = nil, + 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? + { + 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)) + } + 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) + }, + disconnectedStateAfterCommit: disconnectedStateAfterCommit, + disconnectedStateAfterRollback: disconnectedStateAfterRollback, + replacementCredentialFingerprint: replacementCredentialFingerprint, + replacementCredentialsStored: replacementCredentialsStored, + rollbackCredentialFingerprint: rollbackCredentialFingerprint, + rollbackCredentialWasMissing: rollbackCredentialWasMissing, + forceRollback: nil, + rollbackCredentialsRestored: nil, + removalIsolationPublished: removalIsolationPublished, + removalCredentialsCleared: removalCredentialsCleared) + 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) + guard prepareState() else { + _ = self.removeCLIProxyAPIArtifactsManifest(manifestURL, fileManager: fileManager) + return nil + } + 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( + 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 + { + 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 + static func markCLIProxyAPIArtifactsUpdateForRollback( + _ update: CLIProxyAPIArtifactsUpdate, + rollbackCredentialsRestored: Bool = false, + 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, + replacementCredentialFingerprint: manifest.replacementCredentialFingerprint, + replacementCredentialsStored: manifest.replacementCredentialsStored, + rollbackCredentialFingerprint: manifest.rollbackCredentialFingerprint, + rollbackCredentialWasMissing: manifest.rollbackCredentialWasMissing, + forceRollback: true, + rollbackCredentialsRestored: rollbackCredentialsRestored || manifest.rollbackCredentialsRestored == true, + removalIsolationPublished: manifest.removalIsolationPublished, + removalCredentialsCleared: manifest.removalCredentialsCleared) + do { + try JSONEncoder().encode(rollbackManifest).write(to: manifestURL, options: [.atomic]) + return true + } catch { + return false + } + } + + @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, + replacementCredentialFingerprint: manifest.replacementCredentialFingerprint, + replacementCredentialsStored: manifest.replacementCredentialsStored, + rollbackCredentialFingerprint: manifest.rollbackCredentialFingerprint, + rollbackCredentialWasMissing: manifest.rollbackCredentialWasMissing, + forceRollback: true, + rollbackCredentialsRestored: true, + removalIsolationPublished: manifest.removalIsolationPublished, + removalCredentialsCleared: manifest.removalCredentialsCleared) + do { + try JSONEncoder().encode(restoredManifest).write(to: manifestURL, options: [.atomic]) + return true + } catch { + return false + } + } + + @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, + replacementCredentialFingerprint: manifest.replacementCredentialFingerprint, + replacementCredentialsStored: true, + rollbackCredentialFingerprint: manifest.rollbackCredentialFingerprint, + rollbackCredentialWasMissing: manifest.rollbackCredentialWasMissing, + 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, + 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, + replacementCredentialFingerprint: manifest.replacementCredentialFingerprint, + replacementCredentialsStored: manifest.replacementCredentialsStored, + rollbackCredentialFingerprint: manifest.rollbackCredentialFingerprint, + rollbackCredentialWasMissing: manifest.rollbackCredentialWasMissing, + forceRollback: manifest.forceRollback, + rollbackCredentialsRestored: manifest.rollbackCredentialsRestored, + removalIsolationPublished: manifest.removalIsolationPublished, + removalCredentialsCleared: true) + do { + try JSONEncoder().encode(clearedManifest).write(to: manifestURL, options: [.atomic]) + return true + } catch { + return false + } + } + + @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, + replacementCredentialFingerprint: manifest.replacementCredentialFingerprint, + replacementCredentialsStored: manifest.replacementCredentialsStored, + rollbackCredentialFingerprint: manifest.rollbackCredentialFingerprint, + rollbackCredentialWasMissing: manifest.rollbackCredentialWasMissing, + 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, + 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 + } + } + guard succeeded else { return false } + 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( + 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) + 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 + 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, + 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, + 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, + fileManager: fileManager) + else { return false } + return self.restoreCLIProxyAPIArtifactsUpdate(update, fileManager: fileManager) + } + 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) + } + + 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] { + var seenPaths: Set = [] + return directories.flatMap { directory in + [ + directory.appendingPathComponent(self.cliProxyAPIUsageFileName, isDirectory: false), + directory.appendingPathComponent(self.cliProxyAPIPendingFileName, isDirectory: false), + // Provider-specific by design: the attributed proxy rows are persisted in the Claude scan cache. + CostUsageClaudeCacheIO.cacheFileURL( + provider: .claude, + cacheRoot: directory.deletingLastPathComponent()), + ] + }.filter { seenPaths.insert($0.path).inserted } + } + + public static func isCLIProxyAPIExplicitlyDisconnected( + stateRoot: URL? = nil, + fileManager: FileManager = .default) -> Bool + { + fileManager.fileExists(atPath: self.cliProxyAPIDisconnectedURL( + stateRoot: stateRoot, + 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 + } + + static func prepareCLIProxyAPIConfigurationGenerationUpdate( + stateRoot: URL? = nil, + fileManager: FileManager = .default) -> CLIProxyAPIConfigurationGenerationUpdate? + { + let destinationURL = self.cliProxyAPIConfigurationGenerationURL( + stateRoot: stateRoot, + fileManager: fileManager) + 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(generation.utf8).write(to: stagedURL, options: [.atomic]) + return CLIProxyAPIConfigurationGenerationUpdate( + stagedURL: stagedURL, + destinationURL: destinationURL, + generation: generation) + } catch { + 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) + } + + 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 + package 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 + } + } + + @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 + } + } + + @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 + { + let root = stateRoot ?? fileManager.urls( + for: .applicationSupportDirectory, + in: .userDomainMask).first! + .appendingPathComponent("CodexBar", isDirectory: true) + 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 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 + { + 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 + } +} + +extension CostUsageCacheLocations { + private static func releaseCLIProxyAPILock(_ descriptor: Int32) { + _ = flock(descriptor, LOCK_UN) + close(descriptor) + } +} diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 5285b651e7..cbccf559dd 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -1,6 +1,8 @@ -// swiftlint:disable file_length +// Cost usage fetch orchestration and provider-specific scan bridges. import Foundation +// swiftlint:disable file_length + public enum CostUsageError: LocalizedError, Sendable { case unsupportedProvider(UsageProvider) case timedOut(seconds: Int) @@ -73,6 +75,7 @@ public struct CostUsageFetcher: Sendable { } else { var options = CostUsageScanner.Options() options.cacheRoot = cacheRoot + options.cliProxyAPIHome = Self.defaultCLIProxyAPIHome() if let calendar { options.calendar = calendar } @@ -206,7 +209,8 @@ public struct CostUsageFetcher: Sendable { cursorCookieHeaderOverride: String? = nil, allowPricingRefresh: Bool = true, refreshPricingInBackground: Bool = true, - includePiSessions: Bool = true) async throws -> CostUsageTokenSnapshot + includePiSessions: Bool = true, + includeClaudeProxyUsage: Bool = true) async throws -> CostUsageTokenSnapshot { try await Self.loadTokenSnapshot( provider: provider, @@ -220,6 +224,7 @@ public struct CostUsageFetcher: Sendable { allowPricingRefresh: allowPricingRefresh, refreshPricingInBackground: refreshPricingInBackground, includePiSessions: includePiSessions, + includeClaudeProxyUsage: includeClaudeProxyUsage, bypassScannerDebounce: false, scannerOptions: self.scannerOptionsOverride()) } @@ -236,13 +241,14 @@ public struct CostUsageFetcher: Sendable { allowPricingRefresh: Bool = true, refreshPricingInBackground: Bool = true, includePiSessions: Bool = true, + includeClaudeProxyUsage: Bool = true, 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, @@ -255,10 +261,46 @@ public struct CostUsageFetcher: Sendable { allowPricingRefresh: allowPricingRefresh, refreshPricingInBackground: refreshPricingInBackground, includePiSessions: includePiSessions, + includeClaudeProxyUsage: includeClaudeProxyUsage, bypassScannerDebounce: bypassScannerDebounce, scannerOptions: options) } + 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( + 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, + forceRefresh: forceRefresh, + historyDays: historyDays, + allowPricingRefresh: allowPricingRefresh, + refreshPricingInBackground: refreshPricingInBackground, + scannerOptions: self.scannerOptionsOverride(), + modelsDevClient: modelsDevClient, + retryUnknownPricing: true)) + } + @available(*, deprecated, message: "Codex token-cost scans are uncapped; this limit is ignored.") public func loadTokenSnapshot( provider: UsageProvider, @@ -284,19 +326,77 @@ public struct CostUsageFetcher: Sendable { refreshPricingInBackground: refreshPricingInBackground) } - private func scannerOptionsOverride() -> CostUsageScanner.Options? { + func scannerOptionsOverride() -> CostUsageScanner.Options? { 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 + } + 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(), + provider: .codex, + codexHomePath: nil) + 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 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(), + provider: .codex, + codexHomePath: nil) + return CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected(stateRoot: options.cacheRoot) + } + package func codexScanCatchUpStatus( codexHomePath: String? = nil, calendar: Calendar? = nil) async -> CodexScanCatchUpStatus @@ -368,12 +468,15 @@ public struct CostUsageFetcher: Sendable { private static let establishedEmptyCodexDailyReport = CostUsageDailyReport(data: [], summary: nil) - 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 = Self.defaultCLIProxyAPIHome() + } // Provider-specific by design: Codex managed profiles relocate sessions and archived_sessions roots. if provider == .codex, let codexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines), @@ -385,6 +488,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, @@ -397,6 +517,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 @@ -479,11 +600,15 @@ 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) // Provider-specific by design: scoped Codex homes exclude ambient Pi sessions from managed-profile totals. - let shouldMergePiUsage = provider != .codex || scopedCodexHomePath?.isEmpty != false + let shouldMergeGlobalCodexUsage = provider != .codex || scopedCodexHomePath?.isEmpty != false await Self.refreshPricingIfAllowed( options: PricingRefreshOptions( provider: provider, @@ -500,6 +625,10 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: allowVertexClaudeFallback, forceRefresh: forceRefresh, bypassScannerDebounce: bypassScannerDebounce) + // Provider-specific by design: Claude's view retains only Anthropic-owned rows, even after disconnect. + if provider == .claude { + options.claudeAttributionFilter = .excludeCodexBackend + } var resolvedPiOptions = overridePiScannerOptions ?? PiSessionCostScanner.Options() if resolvedPiOptions.cacheRoot == nil { resolvedPiOptions.cacheRoot = options.cacheRoot @@ -514,7 +643,8 @@ public struct CostUsageFetcher: Sendable { let localScanOptions = LocalTokenScanOptions( allowVertexClaudeFallback: allowVertexClaudeFallback, includePiSessions: includePiSessions, - shouldMergePiUsage: shouldMergePiUsage, + includeClaudeProxyUsage: includeClaudeProxyUsage, + shouldMergeGlobalCodexUsage: shouldMergeGlobalCodexUsage, scanOptions: scanOptions, piOptions: piOptions) let scanResult = try await Self.loadLocalTokenScanResult( @@ -545,6 +675,7 @@ public struct CostUsageFetcher: Sendable { allowPricingRefresh: allowPricingRefresh, refreshPricingInBackground: false, includePiSessions: includePiSessions, + includeClaudeProxyUsage: includeClaudeProxyUsage, scannerOptions: options, piScannerOptions: piOptions, modelsDevClient: modelsDevClient, @@ -552,15 +683,10 @@ public struct CostUsageFetcher: Sendable { } return Self.tokenSnapshot( - from: scanResult.daily, + from: scanResult, now: now, historyDays: clampedHistoryDays, - calendar: scanOptions.calendar, - historyCoverageIsEstablished: scanResult.historyCoverageIsEstablished, - costProvenance: .listPriceEstimate, - projects: scanResult.projects, - sessions: scanResult.sessions, - updatedAt: scanResult.staleSnapshotUpdatedAt) + calendar: scanOptions.calendar) } private struct LocalTokenScanResult: Sendable { @@ -571,10 +697,29 @@ public struct CostUsageFetcher: Sendable { let historyCoverageIsEstablished: Bool } + private static func tokenSnapshot( + from result: LocalTokenScanResult, + now: Date, + historyDays: Int, + calendar: Calendar) -> CostUsageTokenSnapshot + { + self.tokenSnapshot( + from: result.daily, + now: now, + historyDays: historyDays, + calendar: calendar, + historyCoverageIsEstablished: result.historyCoverageIsEstablished, + costProvenance: .listPriceEstimate, + projects: result.projects, + sessions: result.sessions, + updatedAt: result.staleSnapshotUpdatedAt) + } + private struct LocalTokenScanOptions: Sendable { let allowVertexClaudeFallback: Bool let includePiSessions: Bool - let shouldMergePiUsage: Bool + let includeClaudeProxyUsage: Bool + let shouldMergeGlobalCodexUsage: Bool let scanOptions: CostUsageScanner.Options let piOptions: PiSessionCostScanner.Options } @@ -620,6 +765,7 @@ public struct CostUsageFetcher: Sendable { var projects: [CostUsageProjectBreakdown] = [] var sessions: [CostUsageSessionBreakdown] = [] var piDaily: CostUsageDailyReport? + var claudeProxyDaily: CostUsageDailyReport? var staleSnapshotUpdatedAt: Date? if provider == .codex { let roots = CostUsageScanner.codexSessionsRoots(options: options.scanOptions) @@ -629,6 +775,15 @@ public struct CostUsageFetcher: Sendable { purpose: .report).scoped(to: roots) let range = CostUsageScanner.CostUsageDayRange( since: since, until: now, calendar: options.scanOptions.calendar) + let supplemental = try Self.loadCodexSupplementalScan( + options: options.scanOptions, + range: range, + now: now, + includeClaudeProxy: options.includeClaudeProxyUsage + && options.shouldMergeGlobalCodexUsage, + checkCancellation: checkCancellation) + claudeProxyDaily = supplemental.claudeProxyDaily + daily = claudeProxyDaily.map { daily.merged(with: $0) } ?? daily if let previous = view.previousReport( range: range, rootsFingerprint: CostUsageScanner.codexRootsFingerprint(options: options.scanOptions)) @@ -645,7 +800,7 @@ public struct CostUsageFetcher: Sendable { } } if options.includePiSessions, - provider == .claude || (provider == .codex && options.shouldMergePiUsage) + provider == .claude || (provider == .codex && options.shouldMergeGlobalCodexUsage) { let piReport = try PiSessionCostScanner.loadDailyReportCancellable( provider: provider, @@ -661,17 +816,20 @@ 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 LocalTokenScanResult( daily: daily, projects: projects, sessions: sessions, staleSnapshotUpdatedAt: staleSnapshotUpdatedAt, + // Provider-specific by design: Codex alone tracks whether its supplemental history is complete. historyCoverageIsEstablished: provider != .codex || Self.codexHistoryCoverageIsEstablished(options: options.scanOptions)) } @@ -728,6 +886,19 @@ public struct CostUsageFetcher: Sendable { for entry in daily.data { for breakdown in entry.modelBreakdowns ?? [] { guard breakdown.costUSD == nil else { continue } + if breakdown.attribution?.route == .cliProxyAPI, + let upstreamModel = breakdown.attribution?.upstream?.model? + .trimmingCharacters(in: .whitespacesAndNewlines), + !upstreamModel.isEmpty + { + for target in CostUsagePricing.claudeModelsDevPricingTargets(for: upstreamModel) { + targets.insert(ModelsDevPricingTarget( + providerID: target.providerID, + modelID: target.modelID)) + } + continue + } + // Provider-specific by design: non-proxy Codex rows use the native OpenAI pricing route. if provider == .codex { guard OpenCodexRouteDispatcher.countsTowardCodexSubscription(modelName: breakdown.modelName) else { continue } @@ -889,147 +1060,232 @@ public struct CostUsageFetcher: Sendable { // Snapshot assembly can touch many SQLite rows; keep it off the cooperative pool // alongside the scans themselves. - let cachedSnapshot: CachedCodexTokenSnapshotResult?? = try? await CostUsageScanExecutor.run { check in - try check() - let clampedHistoryDays = max(1, min(365, historyDays)) - let options = Self.resolvedScannerOptions( - overrideScannerOptions, - provider: .codex, - codexHomePath: codexHomePath) - let until = now - let since = options.calendar.date( - byAdding: .day, - value: -(clampedHistoryDays - 1), - to: now) ?? now - let range = CostUsageScanner.CostUsageDayRange( - since: since, - until: until, - calendar: options.calendar) - let shouldMergePiUsage = scopedCodexHomePath?.isEmpty != false - let roots = CostUsageScanner.codexSessionsRoots(options: options) - let rootsFingerprint = CostUsageScanner.codexRootsFingerprint(options: options) - let loadedCache = CostUsageStoreAccess.readView( - cacheRoot: options.cacheRoot, - calendar: options.calendar, - purpose: .report) - let cache = loadedCache.scoped(to: roots) - var reports: [CostUsageDailyReport] = [] - var projects: [CostUsageProjectBreakdown] = [] - var sessions: [CostUsageSessionBreakdown] = [] - // Raw inputs for the derived result fields below: the native cache's own scan - // time, every constituent scan time, and whether a second source joined the merge. - var nativeScanAt: Date? - var scanTimes: [Date] = [] - var piMerged = false - var staleSnapshotUpdatedAt: Date? - let nativeHistoryCoverageIsEstablished = cache.historyCoverageIsEstablished( - range: range, - rootsFingerprint: rootsFingerprint) - // Final catch-up publication must not fall back to the report from before a new pending scan. - guard !requireCompleteHistory || nativeHistoryCoverageIsEstablished else { return nil } + let cachedSnapshot: CachedCodexTokenSnapshotResult?? = try? await CostUsageScanExecutor + .run { checkCancellation in + try checkCancellation() + let clampedHistoryDays = max(1, min(365, historyDays)) + let options = Self.resolvedScannerOptions( + overrideScannerOptions, + provider: .codex, + codexHomePath: codexHomePath) + let until = now + let since = options.calendar.date( + byAdding: .day, + value: -(clampedHistoryDays - 1), + to: now) ?? now + let range = CostUsageScanner.CostUsageDayRange( + since: since, + until: until, + calendar: options.calendar) + let shouldMergePiUsage = scopedCodexHomePath?.isEmpty != false + let roots = CostUsageScanner.codexSessionsRoots(options: options) + let rootsFingerprint = CostUsageScanner.codexRootsFingerprint(options: options) + let loadedCache = CostUsageStoreAccess.readView( + cacheRoot: options.cacheRoot, + calendar: options.calendar, + purpose: .report) + let cache = loadedCache.scoped(to: roots) + var reports: [CostUsageDailyReport] = [] + var projects: [CostUsageProjectBreakdown] = [] + var sessions: [CostUsageSessionBreakdown] = [] + // Raw inputs for the derived result fields below: the native cache's own scan + // time, every constituent scan time, and whether a second source joined the merge. + var nativeScanAt: Date? + var scanTimes: [Date] = [] + var piMerged = false + var claudeProxyMerged = false + var staleSnapshotUpdatedAt: Date? + let nativeHistoryCoverageIsEstablished = cache.historyCoverageIsEstablished( + range: range, + rootsFingerprint: rootsFingerprint) + // Final catch-up publication must not fall back to the report from before a new pending scan. + guard !requireCompleteHistory || nativeHistoryCoverageIsEstablished else { return nil } - if let previous = cache.previousReport( - range: range, - rootsFingerprint: rootsFingerprint) - { - reports.append(previous.report) - staleSnapshotUpdatedAt = previous.updatedAt - if let updatedAt = previous.updatedAt { - scanTimes.append(updatedAt) - } - } else if cache.timeZoneIdentifier == range.calendar.timeZone.identifier, - !cache.days.isEmpty, - cache.roots == rootsFingerprint, - !cache.windowExpandsCache(range) - { - let daily = cache.dailyReport( + if let previous = cache.previousReport( range: range, - cacheRoot: options.cacheRoot) - if !daily.data.isEmpty { - reports.append(daily) - if cache.lastScanUnixMs > 0 { - let scanAt = Date(timeIntervalSince1970: TimeInterval(cache.lastScanUnixMs) / 1000) - nativeScanAt = scanAt - scanTimes.append(scanAt) + rootsFingerprint: rootsFingerprint) + { + reports.append(previous.report) + staleSnapshotUpdatedAt = previous.updatedAt + if let updatedAt = previous.updatedAt { + scanTimes.append(updatedAt) } - if includeProjectAndSessionBreakdowns { - sessions = cache.sessions( - range: range, - cacheRoot: options.cacheRoot, - roots: roots) - if cache.projectMetadataVersion == CostUsageScanner.codexProjectMetadataVersion { - projects.append(contentsOf: cache.projects( + } else if cache.timeZoneIdentifier == range.calendar.timeZone.identifier, + !cache.days.isEmpty, + cache.roots == rootsFingerprint, + !cache.windowExpandsCache(range) + { + let daily = cache.dailyReport( + range: range, + cacheRoot: options.cacheRoot) + if !daily.data.isEmpty { + reports.append(daily) + if cache.lastScanUnixMs > 0 { + let scanAt = Date(timeIntervalSince1970: TimeInterval(cache.lastScanUnixMs) / 1000) + nativeScanAt = scanAt + scanTimes.append(scanAt) + } + if includeProjectAndSessionBreakdowns { + sessions = cache.sessions( range: range, - cacheRoot: options.cacheRoot)) + cacheRoot: options.cacheRoot, + roots: roots) + if cache.projectMetadataVersion == CostUsageScanner.codexProjectMetadataVersion { + projects.append(contentsOf: cache.projects( + range: range, + cacheRoot: options.cacheRoot)) + } } } } - } - // A completed scan can legitimately have no rows (a fresh account or a quiet - // window). Keep that established-empty state across app restarts instead of - // collapsing it back to "unavailable" merely because the cache has no day map. - if reports.isEmpty, nativeHistoryCoverageIsEstablished { - reports.append(Self.establishedEmptyCodexDailyReport) - if cache.lastScanUnixMs > 0 { - let scanAt = Date(timeIntervalSince1970: TimeInterval(cache.lastScanUnixMs) / 1000) - nativeScanAt = scanAt - scanTimes.append(scanAt) + // A completed scan can legitimately have no rows (a fresh account or a quiet + // window). Keep that established-empty state across app restarts instead of + // collapsing it back to "unavailable" merely because the cache has no day map. + if reports.isEmpty, nativeHistoryCoverageIsEstablished { + reports.append(Self.establishedEmptyCodexDailyReport) + if cache.lastScanUnixMs > 0 { + let scanAt = Date(timeIntervalSince1970: TimeInterval(cache.lastScanUnixMs) / 1000) + nativeScanAt = scanAt + scanTimes.append(scanAt) + } } - } - if includePiSessions, shouldMergePiUsage { - let piResult = PiSessionCostScanner.loadCachedDailyReportResult( - provider: .codex, - since: since, - until: until, - now: now, - cacheRoot: options.cacheRoot, - calendar: options.calendar, - allowEstablishedEmpty: requireCompleteHistory) - // Missing or incompatible mirror history is not zero usage. - guard !requireCompleteHistory || piResult != nil else { return nil } - if let piResult { - reports.append(piResult.report) - piMerged = true - if let piLastScanAt = piResult.lastScanAt { - scanTimes.append(piLastScanAt) - } - if let piProject = Self.unknownProjectBreakdown(from: piResult.report) { - projects.append(piProject) + if shouldMergePiUsage, + let proxy = try Self.loadCachedCodexProxyReport( + options: options, + range: range, + now: now, + checkCancellation: checkCancellation) + { + reports.append(proxy.report) + claudeProxyMerged = true + if let scanAt = proxy.scanAt { + scanTimes.append(scanAt) } - if !piResult.report.data.isEmpty { - sessions = [] + Self.appendCachedCodexProxyProject( + proxy.project, + includeProjectAndSessionBreakdowns: includeProjectAndSessionBreakdowns, + projects: &projects, + sessions: &sessions) + } + + if includePiSessions, shouldMergePiUsage { + let piResult = PiSessionCostScanner.loadCachedDailyReportResult( + provider: .codex, + since: since, + until: until, + now: now, + cacheRoot: options.cacheRoot, + calendar: options.calendar, + allowEstablishedEmpty: requireCompleteHistory) + // Missing or incompatible mirror history is not zero usage. + guard !requireCompleteHistory || piResult != nil else { return nil } + if let piResult { + reports.append(piResult.report) + piMerged = true + if let piLastScanAt = piResult.lastScanAt { + scanTimes.append(piLastScanAt) + } + if includeProjectAndSessionBreakdowns, + let piProject = Self.unknownProjectBreakdown(from: piResult.report) + { + projects.append(piProject) + } + if includeProjectAndSessionBreakdowns, !piResult.report.data.isEmpty { + sessions = [] + } } } + + try checkCancellation() + guard !reports.isEmpty else { return nil } + // `previous` is an exact report captured before the current bounded refresh became + // pending. Its rows remain established even though native catch-up is still active; + // `staleSnapshotUpdatedAt` keeps refresh scheduling and stale presentation explicit. + let displayedHistoryCoverageIsEstablished = nativeHistoryCoverageIsEstablished + || staleSnapshotUpdatedAt != nil + // updatedAt keeps the caches' real (oldest) scan time; stamping the hydration time + // would let stale token rows inherit app-start freshness (#1964). lastRefreshAt + // drives TTL suppression and stays native-only: a merged load must never delay a + // rescan on the strength of another source's scan. + return CachedCodexTokenSnapshotResult( + snapshot: Self.tokenSnapshot( + from: CostUsageDailyReport.merged(reports), + now: now, + historyDays: clampedHistoryDays, + calendar: options.calendar, + historyCoverageIsEstablished: displayedHistoryCoverageIsEstablished, + costProvenance: .listPriceEstimate, + projects: Self.mergedProjectBreakdowns(projects), + sessions: sessions, + updatedAt: scanTimes.min()), + lastRefreshAt: piMerged || claudeProxyMerged || staleSnapshotUpdatedAt != nil ? nil : nativeScanAt, + staleSnapshotUpdatedAt: staleSnapshotUpdatedAt) } + return cachedSnapshot.flatMap(\.self) + } - try check() - guard !reports.isEmpty else { return nil } - // `previous` is an exact report captured before the current bounded refresh became - // pending. Its rows remain established even though native catch-up is still active; - // `staleSnapshotUpdatedAt` keeps refresh scheduling and stale presentation explicit. - let displayedHistoryCoverageIsEstablished = nativeHistoryCoverageIsEstablished - || staleSnapshotUpdatedAt != nil - // updatedAt keeps the caches' real (oldest) scan time; stamping the hydration time - // would let stale token rows inherit app-start freshness (#1964). lastRefreshAt - // drives TTL suppression and stays native-only: a merged load must never delay a - // rescan on the strength of another source's scan. - return CachedCodexTokenSnapshotResult( - snapshot: Self.tokenSnapshot( - from: CostUsageDailyReport.merged(reports), - now: now, - historyDays: clampedHistoryDays, - calendar: options.calendar, - historyCoverageIsEstablished: displayedHistoryCoverageIsEstablished, - costProvenance: .listPriceEstimate, - projects: Self.mergedProjectBreakdowns(projects), - sessions: sessions, - updatedAt: scanTimes.min()), - lastRefreshAt: piMerged || staleSnapshotUpdatedAt != nil ? nil : nativeScanAt, - staleSnapshotUpdatedAt: staleSnapshotUpdatedAt) + private static func appendCachedCodexProxyProject( + _ project: CostUsageProjectBreakdown?, + includeProjectAndSessionBreakdowns: Bool, + projects: inout [CostUsageProjectBreakdown], + sessions: inout [CostUsageSessionBreakdown]) + { + if includeProjectAndSessionBreakdowns, let project { + projects.append(project) + sessions = [] } - return cachedSnapshot.flatMap(\.self) + } + + private static func loadCachedCodexProxyReport( + options: CostUsageScanner.Options, + range: CostUsageScanner.CostUsageDayRange, + now: Date, + checkCancellation: @escaping CostUsageScanner.CancellationCheck) throws -> ( + report: CostUsageDailyReport, + scanAt: Date?, + project: CostUsageProjectBreakdown?)? + { + guard self.isCLIProxyAPIAttributionEnabled(options: options) else { return nil } + // Provider-specific by design: attributed proxy history is read from the Claude scanner's persistent cache. + let claudeCache = CostUsageClaudeCacheIO.load( + provider: .claude, + cacheRoot: options.cacheRoot, + calendar: range.calendar) + guard !claudeCache.days.isEmpty, + !CostUsageScanner.requestedWindowExpandsCache(range: range, cache: claudeCache) + else { return nil } + + let attributionResolver: CLIProxyAPIAttributionResolver? = if let home = options.cliProxyAPIHome { + try CLIProxyAPIAttributionResolver.load( + home: home, + cacheRoot: options.cacheRoot, + forceReload: options.forceRescan, + checkCancellation: checkCancellation) + } else { + nil + } + let report = CostUsageScanner.buildClaudeReportFromCache( + cache: claudeCache, + range: range, + attributionFilter: .codexBackendOnly, + attributionResolver: attributionResolver, + modelsDevCatalog: CostUsagePricing.modelsDevCatalog( + now: now, + cacheRoot: options.cacheRoot), + modelsDevCacheRoot: options.cacheRoot) + guard !report.data.isEmpty else { return nil } + + let scanAt = claudeCache.lastScanUnixMs > 0 + ? Date(timeIntervalSince1970: TimeInterval(claudeCache.lastScanUnixMs) / 1000) + : nil + return ( + report, + scanAt, + self.unknownProjectBreakdown( + from: report, + name: "Claude Code via CLIProxyAPI")) } /// Providers whose token-cost snapshot `loadTokenSnapshot` can produce. Cursor is @@ -1349,6 +1605,201 @@ public struct CostUsageFetcher: Sendable { sessions: sessions, updatedAt: updatedAt ?? now) } +} + +extension CostUsageFetcher { + private struct CodexProxyTokenSnapshotOptions { + let now: Date + let forceRefresh: Bool + let historyDays: Int + let allowPricingRefresh: Bool + let refreshPricingInBackground: Bool + let scannerOptions: CostUsageScanner.Options? + let modelsDevClient: ModelsDevClient + let retryUnknownPricing: Bool + } + + private struct CodexSupplementalScan { + let claudeProxyDaily: CostUsageDailyReport? + } + + private static func loadCodexProxyTokenSnapshot( + _ request: CodexProxyTokenSnapshotOptions) async throws -> CostUsageTokenSnapshot + { + let clampedHistoryDays = max(1, min(365, request.historyDays)) + // Provider-specific by design: this loader publishes the synthetic proxy source in the Codex spend family. + var options = Self.resolvedScannerOptions( + request.scannerOptions, + provider: .codex, + codexHomePath: nil) + let since = options.calendar.date( + byAdding: .day, + value: -(clampedHistoryDays - 1), + to: request.now) ?? request.now + await Self.refreshPricingIfAllowed( + options: PricingRefreshOptions( + provider: .codex, + isAllowed: request.allowPricingRefresh, + retryUnknown: request.retryUnknownPricing, + inBackground: request.refreshPricingInBackground), + now: request.now, + cacheRoot: options.cacheRoot, + client: request.modelsDevClient) + if request.forceRefresh { + options.refreshMinIntervalSeconds = 0 + } + + let scan = options + let proxyDaily = try await CostUsageScanExecutor.run { checkCancellation in + let range = CostUsageScanner.CostUsageDayRange(since: since, until: request.now, calendar: scan.calendar) + let supplemental = try Self.loadCodexSupplementalScan( + options: scan, + 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] } ?? [] + if request.allowPricingRefresh, + request.retryUnknownPricing, + // Provider-specific by design: proxy rows are rendered under Codex while pricing follows their upstream. + 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, + historyDays: clampedHistoryDays, + calendar: options.calendar, + costProvenance: .listPriceEstimate, + projects: projects) + } + + private static func loadCodexSupplementalScan( + options: CostUsageScanner.Options, + range: CostUsageScanner.CostUsageDayRange, + now: Date, + includeClaudeProxy: Bool, + checkCancellation: @escaping CostUsageScanner.CancellationCheck) throws -> CodexSupplementalScan + { + guard includeClaudeProxy, + self.hasCodexProxyEvidence(options: options) + else { + return CodexSupplementalScan(claudeProxyDaily: nil) + } + var proxyOptions = options + proxyOptions.claudeLogProviderFilter = .excludeVertexAI + proxyOptions.claudeAttributionFilter = .codexBackendOnly + // Provider-specific by design: the Codex projection reuses Claude transcripts filtered to proxy-owned rows. + let proxyDaily = try CostUsageScanner.loadClaudeDaily( + provider: .claude, + range: range, + now: now, + options: proxyOptions, + checkCancellation: checkCancellation) + return CodexSupplementalScan(claudeProxyDaily: proxyDaily.data.isEmpty ? nil : proxyDaily) + } + + static func hasCodexProxyEvidence( + options: CostUsageScanner.Options, + fileManager: FileManager = .default) -> Bool + { + guard self.isCLIProxyAPIAttributionEnabled(options: options, fileManager: fileManager) else { return false } + + if CLIProxyAPIUsageCacheIO.load(cacheRoot: options.cacheRoot).contains(where: { + // Provider-specific by design: a raw Codex usage record is direct evidence for proxy attribution. + $0.provider.caseInsensitiveCompare("codex") == .orderedSame + }) { + return true + } + + let cachedClaude = CostUsageClaudeCacheIO.load( + provider: .claude, + cacheRoot: options.cacheRoot, + calendar: options.calendar) + 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 } + if CLIProxyAPIAttributionResolver.hasCodexOAuthModelAliasRoute( + home: home, + fileManager: fileManager) + { + return true + } + 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 isCLIProxyAPIAttributionEnabled( + options: CostUsageScanner.Options, + fileManager: FileManager = .default) -> Bool + { + !CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( + stateRoot: options.cacheRoot, + fileManager: fileManager) + } + + 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) + } package static func resolvedCodexScanDurationPerRefresh( provider: UsageProvider, @@ -1405,10 +1856,13 @@ public struct CostUsageFetcher: Sendable { configuredDuration: options.maxCodexScanDurationPerRefresh) } - private static func unknownProjectBreakdown(from daily: CostUsageDailyReport) -> CostUsageProjectBreakdown? { + 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, @@ -1416,7 +1870,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, @@ -1428,12 +1882,12 @@ public struct CostUsageFetcher: Sendable { static func mergedProjectBreakdowns( _ projects: [CostUsageProjectBreakdown]) -> [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 @@ -1448,7 +1902,7 @@ public struct CostUsageFetcher: Sendable { ] : 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)) @@ -1458,7 +1912,7 @@ public struct CostUsageFetcher: Sendable { 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, @@ -1482,15 +1936,25 @@ public struct CostUsageFetcher: Sendable { } } + 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, @@ -1528,28 +1992,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 static func projectModelBreakdowns( + private struct ProjectBreakdownKey: Hashable { + let modelName: String + let attribution: CostUsageAttribution? + } + + 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 @@ -1562,10 +2040,17 @@ public struct CostUsageFetcher: Sendable { 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 } } +} +extension CostUsageFetcher { static func selectCurrentSession(from sessions: [CostUsageSessionReport.Entry]) -> CostUsageSessionReport.Entry? { diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index 5447fe38e7..6b26719e01 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -359,6 +359,143 @@ 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 other + 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 { + // Provider-specific by design: CLIProxyAPI stores upstream provider identity as an external raw string. + self.provider.trimmingCharacters(in: .whitespacesAndNewlines) + .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() { + 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 cliProxyModelAlias + 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 + } + + 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 { public struct ModelBreakdown: Sendable, Decodable, Equatable { public let modelName: String @@ -374,6 +511,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 @@ -391,6 +529,7 @@ public struct CostUsageDailyReport: Sendable, Decodable { case priorityCostUSD case standardTokens case priorityTokens + case attribution } public init(from decoder: Decoder) throws { @@ -412,6 +551,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( @@ -427,7 +567,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 @@ -442,6 +583,7 @@ public struct CostUsageDailyReport: Sendable, Decodable { self.priorityCostUSD = priorityCostUSD self.standardTokens = standardTokens self.priorityTokens = priorityTokens + self.attribution = attribution } } @@ -718,46 +860,63 @@ public struct CostUsageDailyReport: Sendable, Decodable { } extension CostUsageDailyReport { - private struct OptionalCountAccumulator { - private(set) var value: Int? - private var overflowed = false - - mutating func add(_ incoming: Int?) { - guard let incoming, incoming >= 0, !self.overflowed else { return } - let (sum, overflow) = (self.value ?? 0).addingReportingOverflow(incoming) - self.value = overflow ? nil : sum - self.overflowed = overflow + 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 tokenMix = CostUsageTokenMix() - var requestCount = OptionalCountAccumulator() - var totalTokens: Int = 0 - var sawTotalTokens = 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) { - self.tokenMix.merge(CostUsageTokenMix( - inputTokens: breakdown.inputTokens, - outputTokens: breakdown.outputTokens, - cacheReadTokens: breakdown.cacheReadTokens, - cacheCreationTokens: breakdown.cacheCreationTokens, - reasoningTokens: breakdown.reasoningTokens)) + self.totalTokens.add(breakdown.totalTokens) self.requestCount.add(breakdown.requestCount) - if let totalTokens = breakdown.totalTokens { - self.totalTokens += totalTokens - self.sawTotalTokens = true - } + 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 @@ -770,57 +929,51 @@ 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(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, - requestCount: self.requestCount.value, - inputTokens: self.tokenMix.inputTokens, - outputTokens: self.tokenMix.outputTokens, - cacheReadTokens: self.tokenMix.cacheReadTokens, - cacheCreationTokens: self.tokenMix.cacheCreationTokens, - reasoningTokens: self.tokenMix.reasoningTokens, + 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 reasoningTokens = OptionalCountAccumulator() - var requestCount = OptionalCountAccumulator() + 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 coverage = CostUsageCoverageAccumulator() var entryCount = 0 var hasExplicitCoverage = false - 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 totalTokens: Int = 0 - var sawTotalTokens = false - var derivedTotalTokensWithoutExplicitTotal: Int = 0 var costUSD: Double = 0 var sawCost = false var modelsUsed: Set = [] - var breakdowns: [String: BreakdownAccumulator] = [:] + var breakdowns: [BreakdownKey: BreakdownAccumulator] = [:] mutating func add(_ entry: Entry) { + 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) self.requestCount.add(entry.requestCount) // Classify each source before combining costs: a priced source cannot price another source's missing rows. @@ -829,31 +982,19 @@ extension CostUsageDailyReport { self.hasExplicitCoverage = self.hasExplicitCoverage || entry.pricedRequestCount != nil || entry.unpricedRequestCount != nil || entry.unmeteredRequestCount != nil || entry.estimatedRequestCount != nil - 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 totalTokens = entry.totalTokens { - self.totalTokens += totalTokens - self.sawTotalTokens = true - } else if entryDerivedTotalTokens > 0 { - self.derivedTotalTokensWithoutExplicitTotal += entryDerivedTotalTokens + 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) + } } if let costUSD = entry.costUSD { self.costUSD += costUSD @@ -864,22 +1005,23 @@ 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) } } } 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 @@ -888,21 +1030,21 @@ 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() let includeCoverage = self.entryCount > 1 || self.hasExplicitCoverage 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.reasoningTokens.value, + inputTokens: self.inputTokens.result, + outputTokens: self.outputTokens.result, + cacheReadTokens: self.cacheReadTokens.result, + cacheCreationTokens: self.cacheCreationTokens.result, + reasoningTokens: self.reasoningTokens.result, totalTokens: totalTokens, - requestCount: self.requestCount.value, + requestCount: self.requestCount.result, costUSD: self.sawCost ? self.costUSD : nil, modelsUsed: modelsUsed, modelBreakdowns: modelBreakdowns, @@ -911,6 +1053,17 @@ extension CostUsageDailyReport { estimatedRequestCount: includeCoverage ? self.coverage.exact?.estimated : nil, pricedRequestCount: includeCoverage ? self.coverage.exact?.priced : nil) } + + 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 + } } public func merged(with other: CostUsageDailyReport) -> CostUsageDailyReport { @@ -942,42 +1095,22 @@ extension CostUsageDailyReport { } private static func mergedSummary(from entries: [Entry]) -> Summary { - var reasoningTokens = OptionalCountAccumulator() - 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 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 { - reasoningTokens.add(entry.reasoningTokens) - 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 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 @@ -985,12 +1118,12 @@ extension CostUsageDailyReport { } return Summary( - totalInputTokens: sawTotalInputTokens ? totalInputTokens : nil, - totalOutputTokens: sawTotalOutputTokens ? totalOutputTokens : nil, - cacheReadTokens: sawTotalCacheReadTokens ? totalCacheReadTokens : nil, - cacheCreationTokens: sawTotalCacheCreationTokens ? totalCacheCreationTokens : nil, - reasoningTokens: reasoningTokens.value, - 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) } @@ -1008,7 +1141,12 @@ extension CostUsageDailyReport { 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/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index a417bf0e0e..41feca3bc0 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 = "50a2507f4c10d080" + static let value = "de5b34ce62e9d3c5" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift index cab6ac8d82..b5d5f5302c 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift @@ -32,6 +32,9 @@ struct CostUsageClaudeFileStamp: Equatable, Sendable { struct CostUsageClaudeReportMemoKey: Equatable, Sendable { let provider: UsageProvider let providerFilter: String + let attributionFilter: String + let cliProxyAPIConfigurationGeneration: String? + let cliProxyAPIAttributionEnabled: Bool let sinceKey: String let untilKey: String let scanSinceKey: String @@ -40,11 +43,13 @@ struct CostUsageClaudeReportMemoKey: Equatable, Sendable { let roots: [String] let cacheArtifactStamp: CostUsageClaudeFileStamp? let pricingArtifactStamp: CostUsageClaudeFileStamp? + let cliProxyUsageArtifactStamp: CostUsageClaudeFileStamp? + let cliProxyAPIInputArtifactFingerprint: [String: CostUsageClaudeFileStamp]? var scanConfiguration: ScanConfiguration { ScanConfiguration( provider: self.provider, - providerFilter: self.providerFilter, + providerFilter: "\(self.providerFilter)|\(self.attributionFilter)", timeZoneIdentifier: self.timeZoneIdentifier, roots: self.roots) } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ModelProvider.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ModelProvider.swift new file mode 100644 index 0000000000..4e32b72683 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ModelProvider.swift @@ -0,0 +1,47 @@ +import Foundation + +extension CostUsagePricing { + static func modelProvider( + for model: String, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil) -> CostUsageAttribution.ModelProvider + { + if self.isBundledOpenAIModel(model) { + return .openAI + } + + let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.lowercased().hasPrefix("gemini-") { + 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, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) != nil + { + return .anthropic + } + + return .unknown + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index f3d441f3cf..2ff4319df0 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -1,5 +1,6 @@ import Foundation +// swiftlint:disable:next type_body_length enum CostUsagePricing { private static let codexPriorityInputTokenLimit = 272_000 static let codexUnattributedModel = "unknown" @@ -534,6 +535,27 @@ enum CostUsagePricing { self.normalizeCodexModel(raw) == self.codexUnattributedModel } + static func isOpenAIModel( + _ model: String, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil) -> Bool + { + if self.isBundledOpenAIModel(model) { + return true + } + return self.modelsDevLookup( + providerID: self.codexModelsDevProviderID, + model: model, + catalog: modelsDevCatalog, + 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 @@ -732,6 +754,31 @@ enum CostUsagePricing { + (Double(max(0, outputTokens)) * outputRate) } + 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 claudeCostUSD( model: String, inputTokens: Int, @@ -1060,4 +1107,42 @@ 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 { + 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+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+CacheHelpers.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift index 24f5a38115..9d68052958 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift @@ -1519,7 +1519,12 @@ extension CostUsageScanner { 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/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 186a0d9904..dec2f8d90b 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 @@ -13,9 +32,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 +52,107 @@ 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 pricingResolver: CostUsagePricing.ClaudeResolver + } + + private static func resolveClaudeModel( + model: String, + tokens: ClaudeRawTokens, + context: ClaudeModelResolutionContext) -> ClaudeModelResolution + { + let modelsDevCatalog = context.pricingResolver.prepareCatalog() + let modelProvider = CostUsagePricing.modelProvider( + for: model, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: nil) + 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 ? resolvedAttribution : nil + 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: modelsDevCatalog, + modelsDevCacheRoot: nil) + let cost: Double? = if resolvedAttribution.route != .cliProxyAPI { + context.pricingResolver.costUSD( + model: model, + inputTokens: tokens.input, + cacheReadInputTokens: tokens.cacheRead, + cacheCreationInputTokens: tokens.cacheCreate, + cacheCreationInputTokens1h: tokens.cacheCreate1h, + outputTokens: tokens.output, + pricingDate: context.pricingDate) + } else if pricingProvider == .openAI { + CostUsagePricing.claudeProxyCodexCostUSD( + model: pricingModel, + inputTokens: tokens.input, + cacheReadInputTokens: tokens.cacheRead, + cacheCreationInputTokens: tokens.cacheCreate, + outputTokens: tokens.output, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: nil) + } else if pricingProvider == .anthropic { + context.pricingResolver.costUSD( + model: pricingModel, + inputTokens: tokens.input, + cacheReadInputTokens: tokens.cacheRead, + cacheCreationInputTokens: tokens.cacheCreate, + cacheCreationInputTokens1h: tokens.cacheCreate1h, + outputTokens: tokens.output, + pricingDate: context.pricingDate) + } else if pricingProvider == .google { + CostUsagePricing.claudeProxyGoogleCostUSD( + model: pricingModel, + inputTokens: tokens.input, + cacheReadInputTokens: tokens.cacheRead, + cacheCreationInputTokens: tokens.cacheCreate, + outputTokens: tokens.output, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: nil) + } else if pricingProvider == .other { + context.pricingResolver.costUSD( + model: pricingModel, + inputTokens: tokens.input, + cacheReadInputTokens: tokens.cacheRead, + cacheCreationInputTokens: tokens.cacheCreate, + cacheCreationInputTokens1h: tokens.cacheCreate1h, + outputTokens: tokens.output, + pricingDate: context.pricingDate) + } else { nil } + let normalizedModel = context.pricingResolver.normalize(model) + return ClaudeModelResolution( + normalizedModel: normalizedModel, + cost: cost, + attribution: attribution) + } + static func defaultClaudeProjectsRoots( options: Options, environment: [String: String] = ProcessInfo.processInfo.environment, @@ -83,6 +212,7 @@ extension CostUsageScanner { range: CostUsageDayRange, providerFilter: ClaudeLogProviderFilter, startOffset: Int64 = 0, + attributionResolver: CLIProxyAPIAttributionResolver? = nil, modelsDevCatalog: ModelsDevCatalog? = nil, modelsDevCacheRoot: URL? = nil) -> ClaudeParseResult { @@ -94,6 +224,7 @@ extension CostUsageScanner { range: range, providerFilter: providerFilter, startOffset: startOffset, + attributionResolver: attributionResolver, pricingResolver: pricingResolver, checkCancellation: nil)) ?? ClaudeParseResult(rows: [], parsedBytes: startOffset) } @@ -103,6 +234,7 @@ extension CostUsageScanner { range: CostUsageDayRange, providerFilter: ClaudeLogProviderFilter, startOffset: Int64 = 0, + attributionResolver: CLIProxyAPIAttributionResolver? = nil, pricingResolver: CostUsagePricing.ClaudeResolver, checkCancellation: CancellationCheck? = nil) throws -> ClaudeParseResult { @@ -177,15 +309,27 @@ extension CostUsageScanner { return } - let cost = pricingResolver.costUSD( + 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.dictionary("metadata")?["sessionId"] as? String + ?? message.dictionary("metadata")?["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) - let costNanos = cost.map { Int(($0 * costScale).rounded()) } ?? 0 + tokens: rawTokens, + context: ClaudeModelResolutionContext( + pricingDate: timestamp, + sessionID: sessionId, + timestampUnixMs: timestampUnixMs, + attributionResolver: attributionResolver, + pricingResolver: pricingResolver)) + let costNanos = modelResolution.cost.map { Int(($0 * costScale).rounded()) } ?? 0 let tokens = ClaudeTokens( input: input, cacheRead: cacheRead, @@ -193,7 +337,7 @@ extension CostUsageScanner { cacheCreate1h: cacheCreate1h, output: output, costNanos: costNanos, - costPriced: cost != nil) + costPriced: modelResolution.cost != nil) guard CostUsageDayRange.isInRange( dayKey: dayKey, @@ -203,18 +347,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.dictionary("metadata")?["sessionId"] as? String - ?? message.dictionary("metadata")?["sessionId"] as? String - let normalizedModel = pricingResolver.normalize(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, @@ -223,7 +362,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. @@ -332,6 +472,161 @@ 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 enum ClaudeAttributionReconciliationKey: Hashable { + case canonical(String) + case unkeyed(path: String, index: Int) + } + + private struct ClaudeAttributionReconciliationItem { + let key: ClaudeAttributionReconciliationKey + let request: CLIProxyAPIAttributionResolver.Request + let modelProvider: CostUsageAttribution.ModelProvider + let cachedAttribution: CostUsageAttribution? + } + + private static func reconcileClaudeAttributions( + cache: inout CostUsageCache, + attributionResolver: CLIProxyAPIAttributionResolver, + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) + { + let items = Self.claudeAttributionReconciliationRows(cache: cache).map { item in + let row = item.row + let modelProvider = if let cachedProvider = row.attribution?.modelProvider, + cachedProvider != .unknown + { + cachedProvider + } else { + CostUsagePricing.modelProvider( + for: row.model, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + } + return ClaudeAttributionReconciliationItem( + key: item.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), + occurrenceID: row.messageId), + modelProvider: modelProvider, + cachedAttribution: row.attribution) + } + let requests = items.map(\.request) + let liveAttributions = attributionResolver.attributions(for: requests) + var replacementKeys: Set = [] + var replacements: [ClaudeAttributionReconciliationKey: CostUsageAttribution] = [:] + 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, + cached: item.cachedAttribution) + } 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.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, + 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 removeCachedCLIProxyAPIAttribution(cache: inout CostUsageCache) { + 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 + } + self.rebuildClaudeDays(cache: &cache) + } + private static func rebuildClaudeDays(cache: inout CostUsageCache) { var days: [String: [String: [Int]]] = [:] @@ -518,6 +813,7 @@ extension CostUsageScanner { let providerFilter: ClaudeLogProviderFilter let forceFullScan: Bool let changedPaths: Set + let attributionResolver: CLIProxyAPIAttributionResolver? let pricingResolver: CostUsagePricing.ClaudeResolver let checkCancellation: CancellationCheck? @@ -527,6 +823,7 @@ extension CostUsageScanner { providerFilter: ClaudeLogProviderFilter, forceFullScan: Bool, changedPaths: Set, + attributionResolver: CLIProxyAPIAttributionResolver?, pricingResolver: CostUsagePricing.ClaudeResolver, checkCancellation: CancellationCheck?) { @@ -535,6 +832,7 @@ extension CostUsageScanner { self.providerFilter = providerFilter self.forceFullScan = forceFullScan self.changedPaths = changedPaths + self.attributionResolver = attributionResolver self.pricingResolver = pricingResolver self.checkCancellation = checkCancellation } @@ -572,6 +870,7 @@ extension CostUsageScanner { range: state.range, providerFilter: state.providerFilter, startOffset: startOffset, + attributionResolver: state.attributionResolver, pricingResolver: state.pricingResolver, checkCancellation: state.checkCancellation) let mergedRows = Self.mergeClaudeRows(existing: cached.claudeRows ?? [], delta: delta.rows) @@ -591,6 +890,7 @@ extension CostUsageScanner { fileURL: url, range: state.range, providerFilter: state.providerFilter, + attributionResolver: state.attributionResolver, pricingResolver: state.pricingResolver, checkCancellation: state.checkCancellation) let usage = Self.makeClaudeFileUsage( @@ -630,6 +930,7 @@ extension CostUsageScanner { return inventory } + // swiftlint:disable:next cyclomatic_complexity function_body_length static func loadClaudeDaily( provider: UsageProvider, range: CostUsageDayRange, @@ -637,6 +938,15 @@ extension CostUsageScanner { options: Options, checkCancellation: CancellationCheck?) throws -> CostUsageDailyReport { + let cliProxyAPIAttributionState = try self.captureClaudeCLIProxyAPIAttributionState( + options: options, + checkCancellation: checkCancellation) + self.claudeCLIProxyAPIAttributionCaptureObserverStore?.observer() + let cliProxyAPIConfigurationGeneration = cliProxyAPIAttributionState.configurationGeneration + 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?() @@ -646,12 +956,20 @@ extension CostUsageScanner { let cacheArtifactStamp = CostUsageClaudeFileStamp.read(at: cacheURL) let pricingURL = ModelsDevCache.cacheFileURL(cacheRoot: options.cacheRoot) let pricingArtifactStamp = CostUsageClaudeFileStamp.read(at: pricingURL) + let cliProxyUsageURL = CLIProxyAPIUsageCacheIO.cacheFileURL(cacheRoot: options.cacheRoot) let reportKey = Self.claudeReportMemoKey( provider: provider, providerFilter: options.claudeLogProviderFilter, + attributionFilter: options.claudeAttributionFilter, + cliProxyAPIConfigurationGeneration: cliProxyAPIConfigurationGeneration, + cliProxyAPIAttributionEnabled: cliProxyAPIAttributionEnabled, + cliProxyAPIInputArtifactFingerprint: cliProxyAPIInputArtifactFingerprint, range: range, roots: roots, - artifactStamps: (cache: cacheArtifactStamp, pricing: pricingArtifactStamp)) + artifactStamps: ( + cache: cacheArtifactStamp, + pricing: pricingArtifactStamp, + proxyUsage: cliProxyUsageArtifactStamp)) let memo = CostUsageClaudeReportMemo.shared let priorMemo = memo.entry(provider: provider, canonicalCachePath: canonicalCachePath) let sourceInventory = inventory.stamps @@ -661,8 +979,26 @@ 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() } + guard !CostUsageCacheLocations.isCLIProxyAPIExplicitlyDisconnected( + stateRoot: options.cacheRoot) == cliProxyAPIAttributionEnabled + 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 + } } var cache = CostUsageClaudeCacheIO.load( @@ -672,6 +1008,9 @@ extension CostUsageScanner { let nowMs = Int64(now.timeIntervalSince1970 * 1000) 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 sourceInventoryChanged = priorMemo.map { $0.sourceInventory != sourceInventory } ?? false let cacheArtifactChanged = priorMemo.map { $0.reportKey.cacheArtifactStamp != cacheArtifactStamp @@ -681,6 +1020,7 @@ extension CostUsageScanner { } ?? false let shouldRefresh = options.forceRescan || windowExpanded + || requiresRowBackfill || sourceInventoryChanged || cacheArtifactChanged || scanConfigurationChanged @@ -711,8 +1051,12 @@ extension CostUsageScanner { cache: cache, range: range, providerFilter: providerFilter, - forceFullScan: options.forceRescan || windowExpanded || scanConfigurationChanged, + forceFullScan: options.forceRescan + || windowExpanded + || scanConfigurationChanged + || requiresRowBackfill, changedPaths: changedPaths, + attributionResolver: attributionResolver, pricingResolver: pricingResolver, checkCancellation: checkCancellation) @@ -733,6 +1077,13 @@ extension CostUsageScanner { cache.files.removeValue(forKey: key) } + if let attributionResolver { + Self.reconcileClaudeAttributions( + cache: &cache, + attributionResolver: attributionResolver, + modelsDevCatalog: pricingResolver.prepareCatalog(), + modelsDevCacheRoot: nil) + } Self.rebuildClaudeDays(cache: &cache) Self.pruneDays(cache: &cache, sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) cache.scanSinceKey = range.scanSinceKey @@ -740,37 +1091,83 @@ extension CostUsageScanner { cache.lastScanUnixMs = nowMs } - let report = Self.buildClaudeReportFromCache( - cache: cache, - range: range, - pricingResolver: pricingResolver) - try checkCancellation?() - - let committedCacheStamp: CostUsageClaudeFileStamp? = if shouldMutateCache { - try CostUsageClaudeCacheIO.save( - provider: provider, + var committedCacheStamp: CostUsageClaudeFileStamp? + let report = try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock( + stateRoot: options.cacheRoot) + { + guard CostUsageCacheLocations.cliProxyAPIConfigurationGeneration( + stateRoot: options.cacheRoot) == cliProxyAPIConfigurationGeneration + 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) + if !reportAttributionEnabled { + Self.removeCachedCLIProxyAPIAttribution(cache: &cache) + } + if shouldMutateCache { + committedCacheStamp = try CostUsageClaudeCacheIO.save( + provider: provider, + cache: cache, + cacheRoot: options.cacheRoot, + calendar: range.calendar, + checkCancellation: checkCancellation) + } + let built = Self.buildClaudeReportFromCache( cache: cache, - cacheRoot: options.cacheRoot, - calendar: range.calendar, - checkCancellation: checkCancellation) - } else { - nil + range: range, + attributionFilter: options.claudeAttributionFilter, + attributionResolver: reportAttributionEnabled ? attributionResolver : nil, + allowCachedCLIProxyAPIAttribution: reportAttributionEnabled, + pricingResolver: pricingResolver) + try checkCancellation?() + 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: (cache: finalCacheArtifactStamp, pricing: finalPricingArtifactStamp)) + artifactStamps: ( + cache: finalCacheArtifactStamp, + pricing: finalPricingArtifactStamp, + proxyUsage: finalCLIProxyUsageArtifactStamp)) let cacheArtifactIsCurrent = if shouldMutateCache { committedCacheStamp != nil && finalCacheArtifactStamp == committedCacheStamp } else { finalCacheArtifactStamp == cacheArtifactStamp } - if cacheArtifactIsCurrent, finalPricingArtifactStamp == pricingArtifactStamp { + if cacheArtifactIsCurrent, + finalPricingArtifactStamp == pricingArtifactStamp, + finalCLIProxyUsageArtifactStamp == cliProxyUsageArtifactStamp, + finalCLIProxyAPIInputArtifactFingerprint == cliProxyAPIInputArtifactFingerprint + { memo.store( provider: provider, canonicalCachePath: canonicalCachePath, @@ -781,12 +1178,20 @@ extension CostUsageScanner { return report } + // swiftlint:disable:next function_parameter_count private static func claudeReportMemoKey( provider: UsageProvider, providerFilter: ClaudeLogProviderFilter, + attributionFilter: ClaudeAttributionFilter, + cliProxyAPIConfigurationGeneration: String?, + cliProxyAPIAttributionEnabled: Bool, + cliProxyAPIInputArtifactFingerprint: [String: CostUsageClaudeFileStamp]?, range: CostUsageDayRange, roots: [URL], - artifactStamps: (cache: CostUsageClaudeFileStamp?, pricing: CostUsageClaudeFileStamp?)) + artifactStamps: ( + cache: CostUsageClaudeFileStamp?, + pricing: CostUsageClaudeFileStamp?, + proxyUsage: CostUsageClaudeFileStamp?)) -> CostUsageClaudeReportMemoKey { let providerFilterKey = switch providerFilter { @@ -794,9 +1199,17 @@ extension CostUsageScanner { case .vertexAIOnly: "vertex-ai-only" case .excludeVertexAI: "exclude-vertex-ai" } + let attributionFilterKey = switch attributionFilter { + case .all: "all" + case .codexBackendOnly: "codex-backend-only" + case .excludeCodexBackend: "exclude-codex-backend" + } return CostUsageClaudeReportMemoKey( provider: provider, providerFilter: providerFilterKey, + attributionFilter: attributionFilterKey, + cliProxyAPIConfigurationGeneration: cliProxyAPIConfigurationGeneration, + cliProxyAPIAttributionEnabled: cliProxyAPIAttributionEnabled, sinceKey: range.sinceKey, untilKey: range.untilKey, scanSinceKey: range.scanSinceKey, @@ -804,71 +1217,261 @@ extension CostUsageScanner { timeZoneIdentifier: range.calendar.timeZone.identifier, roots: roots.map { $0.standardizedFileURL.resolvingSymlinksInPath().path }.sorted(), cacheArtifactStamp: artifactStamps.cache, - pricingArtifactStamp: artifactStamps.pricing) + pricingArtifactStamp: artifactStamps.pricing, + cliProxyUsageArtifactStamp: artifactStamps.proxyUsage, + cliProxyAPIInputArtifactFingerprint: cliProxyAPIInputArtifactFingerprint) + } + + private struct ClaudeReportAggregation { + var dayModels: [String: [ClaudeDayModelKey: [Int]]] = [:] + var repricedCosts: [ClaudeDayModelKey: ClaudeRepricedCost] = [:] + } + + private struct ClaudeAttributionAggregationContext { + let filter: ClaudeAttributionFilter + let resolver: CLIProxyAPIAttributionResolver? + let allowCachedCLIProxyAPIAttribution: Bool } - private static func buildClaudeReportFromCache( + private static func aggregateClaudeRows( cache: CostUsageCache, - range: CostUsageDayRange, - pricingResolver: CostUsagePricing.ClaudeResolver) -> CostUsageDailyReport + attributionContext: ClaudeAttributionAggregationContext, + pricingResolver: CostUsagePricing.ClaudeResolver) -> ClaudeReportAggregation { - 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 - var repricedCosts: [ClaudeDayModelKey: ClaudeRepricedCost] = [:] + var result = ClaudeReportAggregation() let rows = Self.reconciledClaudeRows(cache: cache) - if !rows.isEmpty { - pricingResolver.prepareCatalog() + guard !rows.isEmpty else { return result } + let modelsDevCatalog = pricingResolver.prepareCatalog() + let rowsWithProviders = rows.map { row in + let modelProvider = if row.attribution?.route == .cliProxyAPI, + let cachedProvider = row.attribution?.modelProvider, + cachedProvider != .unknown + { + cachedProvider + } else { + CostUsagePricing.modelProvider( + for: row.model, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: nil) + } + 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), + occurrenceID: item.row.messageId) + } + let liveAttributions: [CostUsageAttribution?] = if let resolver = attributionContext.resolver { + resolver.attributions(for: requests).map(Optional.some) + } else { + Array(repeating: nil, count: rowsWithProviders.count) } - for row in rows { + for (index, item) in rowsWithProviders.enumerated() { + let row = item.row + let request = requests[index] + let liveAttribution = liveAttributions[index] + let cachedAttribution: CostUsageAttribution? = + if !attributionContext.allowCachedCLIProxyAPIAttribution, + row.attribution?.route == .cliProxyAPI { + nil + } else { + row.attribution + } + let attribution: CostUsageAttribution? = if let liveAttribution, + liveAttribution.route == .cliProxyAPI + { + Self.preferredCLIProxyAPIAttribution(live: liveAttribution, cached: cachedAttribution) + } else if Self.shouldPreserveCachedCLIProxyAPIAttribution( + row.attribution, + allowCached: attributionContext.allowCachedCLIProxyAPIAttribution, + hasMatchingObservation: attributionContext.resolver?.hasMatchingObservation(for: request) == true) + { + row.attribution + } else if item.modelProvider != .anthropic { + liveAttribution ?? cachedAttribution + } else { + 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 { + item.modelProvider != .anthropic && item.modelProvider != .unknown + } + let includeRow = switch attributionContext.filter { + case .all: true + case .codexBackendOnly: isCodexBackend + case .excludeCodexBackend: !isCodexBackend && !isNonClaudeProxyBackend && !isUnresolvedAttribution + } + guard includeRow else { continue } + #if DEBUG Self.recordClaudeScanWork(.reprice) #endif - 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 = pricingResolver.costUSD( - model: row.model, + let key = ClaudeDayModelKey(day: row.dayKey, model: row.model, attribution: attribution) + var models = result.dayModels[row.dayKey] ?? [:] + 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 repriced = result.repricedCosts[key] ?? ClaudeRepricedCost() + repriced.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 cachedUpstreamModel = row.attribution?.route == .cliProxyAPI + ? row.attribution?.upstream?.model?.trimmingCharacters(in: .whitespacesAndNewlines) + : nil + let cachedPricingModel = cachedUpstreamModel.flatMap { $0.isEmpty ? nil : $0 } ?? row.model + let pricingProvider = CostUsagePricing.modelProvider( + for: pricingModel, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: nil) + let currentCost = Self.currentClaudeRowCost( + row, + pricingModel: pricingModel, + pricingProvider: pricingProvider, + pricingResolver: pricingResolver) + let resolvedCost = Self.resolvedClaudeRowCost( + wasPriced: wasPriced, + cachedCostNanos: row.costNanos, + cachedPricingModel: cachedPricingModel, + pricingModel: pricingModel, + currentCost: currentCost) + if let resolvedCost { + repriced.total += resolvedCost + } else { + repriced.unresolved = true + } + result.repricedCosts[key] = repriced + } + return result + } + + static func shouldPreserveCachedCLIProxyAPIAttribution( + _ cached: CostUsageAttribution?, + allowCached: Bool, + hasMatchingObservation: Bool) -> Bool + { + guard allowCached, + let cached, + cached.route == .cliProxyAPI + else { return false } + return !hasMatchingObservation || cached.evidence.contains(.cliProxyUsageTelemetry) + } + + static func resolvedClaudeRowCost( + wasPriced: Bool, + cachedCostNanos: Int, + cachedPricingModel: String, + pricingModel: String, + currentCost: Double?) -> 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) / 1_000_000_000.0 + } + + private static func currentClaudeRowCost( + _ row: ClaudeUsageRow, + pricingModel: String, + pricingProvider: CostUsageAttribution.ModelProvider, + pricingResolver: CostUsagePricing.ClaudeResolver) -> Double? + { + let modelsDevCatalog = pricingResolver.prepareCatalog() + if pricingProvider == .openAI { + return CostUsagePricing.claudeProxyCodexCostUSD( + model: pricingModel, 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) - }) - let cost: Double? = if isPriced, row.costNanos == 0 { - 0 - } else if let currentPricingCost { - currentPricingCost - } else if isPriced { - Double(row.costNanos) / costScale - } else { - nil - } - if let cost { - aggregate.total += cost - } else { - aggregate.unresolved = true - } - repricedCosts[key] = aggregate + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: nil) + } + if pricingProvider == .google { + return CostUsagePricing.claudeProxyGoogleCostUSD( + model: pricingModel, + inputTokens: row.input, + cacheReadInputTokens: row.cacheRead, + cacheCreationInputTokens: row.cacheCreate, + outputTokens: row.output, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: nil) } + guard pricingProvider == .anthropic || pricingProvider == .other || pricingProvider == .unknown + else { return nil } + return pricingResolver.costUSD( + model: pricingModel, + 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) }) + } - let dayKeys = cache.days.keys.sorted().filter { + static func buildClaudeReportFromCache( + cache: CostUsageCache, + range: CostUsageDayRange, + attributionFilter: ClaudeAttributionFilter = .all, + attributionResolver: CLIProxyAPIAttributionResolver? = nil, + allowCachedCLIProxyAPIAttribution: Bool = true, + pricingResolver: CostUsagePricing.ClaudeResolver) -> 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, + attributionContext: .init( + filter: attributionFilter, + resolver: attributionResolver, + allowCachedCLIProxyAPIAttribution: allowCachedCLIProxyAPIAttribution), + pricingResolver: pricingResolver) + 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?.deterministicSortKey ?? "") + < ($1.attribution?.deterministicSortKey ?? "") + } var dayInput = 0 var dayOutput = 0 @@ -879,8 +1482,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 @@ -894,7 +1498,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 @@ -908,7 +1512,13 @@ extension CostUsageScanner { CostUsageDailyReport.ModelBreakdown( modelName: model, costUSD: cost, - totalTokens: totalTokens)) + totalTokens: totalTokens, + requestCount: sampleCount, + inputTokens: input, + outputTokens: output, + cacheReadTokens: cacheRead, + cacheCreationTokens: cacheCreate, + attribution: modelKey.attribution)) if let cost { dayCost += cost dayCostSeen = true @@ -927,7 +1537,7 @@ extension CostUsageScanner { cacheCreationTokens: dayCacheCreate, totalTokens: dayTotal, costUSD: entryCost, - modelsUsed: modelNames, + modelsUsed: Array(Set(modelKeys.map(\.model))).sorted(), modelBreakdowns: sortedBreakdown)) totalInput += dayInput @@ -953,4 +1563,24 @@ extension CostUsageScanner { return CostUsageDailyReport(data: entries, summary: summary) } + + static func buildClaudeReportFromCache( + cache: CostUsageCache, + range: CostUsageDayRange, + attributionFilter: ClaudeAttributionFilter = .all, + attributionResolver: CLIProxyAPIAttributionResolver? = nil, + allowCachedCLIProxyAPIAttribution: Bool = true, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil) -> CostUsageDailyReport + { + let pricingResolver = modelsDevCatalog.map { CostUsagePricing.ClaudeResolver(catalog: $0) } + ?? CostUsagePricing.ClaudeResolver(now: Date(), cacheRoot: modelsDevCacheRoot) + return Self.buildClaudeReportFromCache( + cache: cache, + range: range, + attributionFilter: attributionFilter, + attributionResolver: attributionResolver, + allowCachedCLIProxyAPIAttribution: allowCachedCLIProxyAPIAttribution, + pricingResolver: pricingResolver) + } } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+ClaudeCLIProxyAPIAttributionState.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+ClaudeCLIProxyAPIAttributionState.swift new file mode 100644 index 0000000000..0ced5e7577 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+ClaudeCLIProxyAPIAttributionState.swift @@ -0,0 +1,55 @@ +extension CostUsageScanner { + struct ClaudeCLIProxyAPIAttributionState { + let configurationGeneration: String? + let attributionEnabled: Bool + let resolver: CLIProxyAPIAttributionResolver? + let usageArtifactStamp: CostUsageClaudeFileStamp? + let inputArtifactFingerprint: [String: 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, + 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/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/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 8cfec670d5..4599a9ecc4 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -53,6 +53,12 @@ enum CostUsageScanner { case excludeVertexAI } + enum ClaudeAttributionFilter { + case all + case codexBackendOnly + case excludeCodexBackend + } + struct CodexScanWorkMetrics: Equatable, Sendable { var usageRowsProcessed: Int var usageRowsRepriced: Int @@ -168,6 +174,8 @@ enum CostUsageScanner { var calendar: Calendar 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 /// Maximum bounded slice read from one Codex rollout per refresh. Larger files @@ -190,6 +198,8 @@ enum CostUsageScanner { codexTraceDatabaseURL: URL? = nil, calendar: Calendar = .current, claudeLogProviderFilter: ClaudeLogProviderFilter = .all, + claudeAttributionFilter: ClaudeAttributionFilter = .all, + cliProxyAPIHome: URL? = nil, forceRescan: Bool = false, maxCodexSessionFileBytes: Int64 = 256 * 1024 * 1024, maxCodexScanBytesPerRefresh: Int64 = 512 * 1024 * 1024, @@ -203,6 +213,8 @@ enum CostUsageScanner { self.codexTraceDatabaseURL = codexTraceDatabaseURL self.calendar = calendar self.claudeLogProviderFilter = claudeLogProviderFilter + self.claudeAttributionFilter = claudeAttributionFilter + self.cliProxyAPIHome = cliProxyAPIHome self.forceRescan = forceRescan self.maxCodexSessionFileBytes = max(0, maxCodexSessionFileBytes) self.maxCodexScanBytesPerRefresh = max(0, maxCodexScanBytesPerRefresh) @@ -1949,6 +1961,43 @@ enum CostUsageScanner { let output: Int let costNanos: Int let costPriced: Bool? + let attribution: CostUsageAttribution? + + init( + dayKey: String, + model: String, + sessionId: String?, + messageId: String?, + requestId: String?, + timestampUnixMs: Int64?, + isSidechain: Bool, + pathRole: ClaudePathRole, + input: Int, + cacheRead: Int, + cacheCreate: Int, + cacheCreate1h: Int?, + output: Int, + costNanos: Int, + costPriced: Bool?, + attribution: CostUsageAttribution? = nil) + { + self.dayKey = dayKey + self.model = model + self.sessionId = sessionId + self.messageId = messageId + self.requestId = requestId + self.timestampUnixMs = timestampUnixMs + self.isSidechain = isSidechain + self.pathRole = pathRole + self.input = input + self.cacheRead = cacheRead + self.cacheCreate = cacheCreate + self.cacheCreate1h = cacheCreate1h + self.output = output + self.costNanos = costNanos + self.costPriced = costPriced + self.attribution = attribution + } } static func loadDailyReport( diff --git a/Tests/CodexBarTests/BrowserDetectionTests.swift b/Tests/CodexBarTests/BrowserDetectionTests.swift index 127385b449..3586bcff6a 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 } 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/CLIProxyAPIAliasRegressionTests.swift b/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift new file mode 100644 index 0000000000..2fa39a3e56 --- /dev/null +++ b/Tests/CodexBarTests/CLIProxyAPIAliasRegressionTests.swift @@ -0,0 +1,285 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CLIProxyAPIAliasRegressionTests { + @Test + 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) + 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 == .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)) + } + } + + @Test + func `codex oauth alias parser ignores inline comments and other providers`() { + let configuration = """ + # oauth-model-alias: + # codex: + # - name: "ignored" + oauth-model-alias: # configured routes + codex: # Codex OAuth aliases + - 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 `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 `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 `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 `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( + 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 `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( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .openAI, + upstream: .init(provider: "codex", authType: .oauth, model: "gpt-5.5"), + evidence: [.cliProxyRequestLog, .cliProxyUsageTelemetry, .modelProvider]) + let requestLogOnly = CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .openAI, + evidence: [.cliProxyRequestLog, .modelProvider]) + + #expect(CostUsageScanner.shouldPreserveCachedCLIProxyAPIAttribution( + telemetry, + allowCached: true, + hasMatchingObservation: true)) + #expect(!CostUsageScanner.shouldPreserveCachedCLIProxyAPIAttribution( + requestLogOnly, + allowCached: true, + hasMatchingObservation: true)) + #expect(CostUsageScanner.shouldPreserveCachedCLIProxyAPIAttribution( + requestLogOnly, + allowCached: true, + hasMatchingObservation: false)) + #expect(!CostUsageScanner.shouldPreserveCachedCLIProxyAPIAttribution( + telemetry, + allowCached: false, + hasMatchingObservation: false)) + } + + @Test + 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..<1).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": "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.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) + } + + 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")) + } +} diff --git a/Tests/CodexBarTests/CLIProxyAPIAttributionBatchTests.swift b/Tests/CodexBarTests/CLIProxyAPIAttributionBatchTests.swift new file mode 100644 index 0000000000..2ce3243744 --- /dev/null +++ b/Tests/CodexBarTests/CLIProxyAPIAttributionBatchTests.swift @@ -0,0 +1,232 @@ +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.5", timestamp: timestamp), + .init( + sessionID: "session-2", + model: "gpt-5.5", + 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.5", + modelProvider: .openAI, + sessionID: "session-1", + timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), + tokens: Self.tokens), + CLIProxyAPIAttributionResolver.Request( + model: "gpt-5.5", + 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.5", timestamp: timestamp), + .init( + sessionID: "session-2", + model: "gpt-5.5", + 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.5", + modelProvider: .openAI, + sessionID: "session-1", + timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), + tokens: Self.tokens), + .init( + model: "gpt-5.5", + modelProvider: .openAI, + sessionID: "session-2", + timestampUnixMs: Int64(timestamp.addingTimeInterval(4).timeIntervalSince1970 * 1000), + tokens: Self.tokens), + ]) + + #expect(attributions.map(\.upstream?.provider) == ["codex", "openrouter"]) + } + + @Test + func `batch route evidence belongs only to the closest request in a resumed session`() { + 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(1).timeIntervalSince1970 * 1000), + tokens: Self.tokens), + .init( + model: "gpt-5.5", + modelProvider: .openAI, + sessionID: "resumed-session", + timestampUnixMs: Int64(timestamp.addingTimeInterval(30).timeIntervalSince1970 * 1000), + tokens: Self.tokens), + ]) + + #expect(attributions.map(\.route) == [.cliProxyAPI, .unknown]) + #expect(attributions[0].evidence.contains(.cliProxyRequestLog)) + #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 `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) + 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, + 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.5", + alias: "gpt-5.5", + 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 new file mode 100644 index 0000000000..4163191e09 --- /dev/null +++ b/Tests/CodexBarTests/CLIProxyAPIAttributionResolverTests.swift @@ -0,0 +1,1330 @@ +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.5", timestamp: timestamp), + ]) + + 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 == [.cliProxyRequestLog, .modelProvider]) + } + + @Test + func `request log does not confirm a distant request in the same session`() { + let timestamp = Date(timeIntervalSince1970: 1_784_179_200) + let resolver = CLIProxyAPIAttributionResolver( + observations: [ + .init(sessionID: "session-1", model: "gpt-5.5", timestamp: timestamp), + ]) + + let attribution = resolver.attribution( + model: "gpt-5.5", + modelProvider: .openAI, + sessionID: "session-1", + timestampUnixMs: Int64(timestamp.addingTimeInterval(3 * 60 * 60).timeIntervalSince1970 * 1000), + tokens: Self.tokens) + + #expect(attribution.route == .unknown) + #expect(attribution.upstream == nil) + #expect(attribution.evidence == [.modelProvider]) + } + + @Test + func `codex auth inventory identifies upstream after this session route is proven`() { + let resolver = CLIProxyAPIAttributionResolver( + observations: [ + .init(sessionID: "logged-session", model: "gpt-5.5", timestamp: nil), + ], + authProviders: [ + .init(provider: "codex", authType: .oauth), + ]) + + let attribution = resolver.attribution( + model: "gpt-5.5", + modelProvider: .openAI, + sessionID: "logged-session", + timestampUnixMs: nil, + tokens: Self.tokens) + + #expect(attribution.route == .cliProxyAPI) + #expect(attribution.upstream == .init( + provider: "codex", + authType: .oauth, + model: "gpt-5.5")) + #expect(attribution.evidence == [ + .cliProxyAuthInventory, + .cliProxyRequestLog, + .modelProvider, + ]) + } + + @Test + func `codex auth inventory does not transfer route proof between sessions`() { + let resolver = CLIProxyAPIAttributionResolver( + observations: [ + .init(sessionID: "logged-session", model: "gpt-5.5", timestamp: nil), + ], + authProviders: [ + .init(provider: "codex", authType: .oauth), + ]) + + let attribution = resolver.attribution( + model: "gpt-5.5", + modelProvider: .openAI, + sessionID: "unrelated-session", + timestampUnixMs: nil, + tokens: Self.tokens) + + #expect(attribution.route == .unknown) + #expect(attribution.upstream == nil) + #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.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 == [.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 `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) + let resolver = CLIProxyAPIAttributionResolver( + observations: [ + .init(sessionID: "session-1", model: "gpt-5.5", timestamp: timestamp), + ], + usageRecords: [ + Self.record( + timestamp: timestamp.addingTimeInterval(1), + provider: "codex", + authType: "oauth"), + ]) + + 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?.provider == "codex") + #expect(attribution.upstream?.authType == .oauth) + #expect(attribution.upstream?.model == "gpt-5.5") + #expect(attribution.evidence == [ + .cliProxyRequestLog, + .cliProxyUsageTelemetry, + .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`() { + let timestamp = Date(timeIntervalSince1970: 1_784_179_200) + let resolver = CLIProxyAPIAttributionResolver( + observations: [ + .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"), + ], + authProviders: [ + .init(provider: "codex", authType: .oauth), + ]) + + 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 == "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) + let resolver = CLIProxyAPIAttributionResolver( + observations: [ + .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.5", + 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 `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) + let resolver = CLIProxyAPIAttributionResolver( + observations: [ + .init(sessionID: "session-1", model: "gpt-5.5", 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.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(.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.5", timestamp: timestamp), + .init( + sessionID: "session-2", + model: "gpt-5.5", + timestamp: timestamp.addingTimeInterval(2)), + ], + usageRecords: [ + Self.record( + timestamp: timestamp.addingTimeInterval(1), + provider: "codex", + authType: "oauth"), + ]) + + let attributions = resolver.attributions(for: ["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) + }) + + for attribution in attributions { + #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.5", timestamp: timestamp), + ], + usageRecords: [ + Self.record( + timestamp: timestamp, + provider: "codex", + authType: "oauth", + failed: true), + CLIProxyAPIUsageRecord( + timestamp: timestamp.addingTimeInterval(1), + provider: "codex", + model: "gpt-5.5", + alias: "gpt-5.5", + endpoint: "POST /v1/messages", + authType: "oauth", + requestID: "request-mismatch", + tokens: .init(input: 999, output: 999, total: 1998)), + ]) + + 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) + } + + @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.5", timestamp: timestamp), + ], + usageRecords: unrelated + [matching]) + + 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.upstream?.model == "gpt-5.5") + #expect(attribution.evidence.contains(.cliProxyUsageTelemetry)) + } + + @Test + func `model without correlated request does not claim cliproxyapi`() { + let resolver = CLIProxyAPIAttributionResolver( + observations: [ + .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.5", + 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.5"} + === 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 = try CLIProxyAPIAttributionResolver.load( + home: home, + cacheRoot: cacheRoot, + 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?.isCodex == true) + #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 = try CLIProxyAPIAttributionResolver.load(home: home, fileManager: fileManager) + let attribution = resolver.attribution( + model: "gpt-5.5", + modelProvider: .openAI, + sessionID: "target-session", + timestampUnixMs: Int64(timestamp.timeIntervalSince1970 * 1000), + tokens: Self.tokens) + + #expect(attribution.route == .cliProxyAPI) + #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 `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 + 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.5", + "alias":"gpt-5.5", + "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.5", + "alias":"gpt-5.5", + "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 batch = try await client.pop(count: 100) + let records = batch.records + + #expect(records.count == 1) + #expect(batch.receivedCount == 1) + #expect(records[0].provider == "codex") + #expect(records[0].authType == "oauth") + #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.5", + alias: "gpt-5.5", + 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 `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.5", + alias: "gpt-5.5", + 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 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: current.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 cache prunes expired records from disk 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: current.timestamp) == 2) + + #expect(CLIProxyAPIUsageCacheIO.load( + cacheRoot: cacheRoot, + now: now).map(\.requestID) == [current.requestID]) + #expect(CLIProxyAPIUsageCacheIO.load( + cacheRoot: cacheRoot, + now: now.addingTimeInterval(1)).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 `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) + #expect(CLIProxyAPIConnectionSettings( + baseURL: "https://[::1]:8317", + 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, + cacheCreate: 40, + output: 20) + + private static func route( + for sessionID: String, + resolver: CLIProxyAPIAttributionResolver) -> CostUsageAttribution.Route + { + resolver.attribution( + model: "gpt-5.5", + modelProvider: .openAI, + sessionID: sessionID, + timestampUnixMs: nil, + tokens: self.tokens).route + } + + 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.5"} + === RESPONSE === + Status: 200 + """ + } + + 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.5", + alias: "gpt-5.5", + 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)) + } +} + +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]) + } +} + +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", + "/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 + + 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 + } +} + +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/CLIProxyAPIPreCredentialRollbackTests.swift b/Tests/CodexBarTests/CLIProxyAPIPreCredentialRollbackTests.swift new file mode 100644 index 0000000000..f6e2d27c96 --- /dev/null +++ b/Tests/CodexBarTests/CLIProxyAPIPreCredentialRollbackTests.swift @@ -0,0 +1,149 @@ +import Foundation +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 + 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/CLIProxyAPIPublicationGuardTests.swift b/Tests/CodexBarTests/CLIProxyAPIPublicationGuardTests.swift new file mode 100644 index 0000000000..63af620c18 --- /dev/null +++ b/Tests/CodexBarTests/CLIProxyAPIPublicationGuardTests.swift @@ -0,0 +1,94 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@MainActor +struct CLIProxyAPIPublicationGuardTests { + @Test + 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]) + 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 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(scannerOptions: scannerOptions), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + let historyDays = settings.costUsageHistoryDays + let scopeSignature = store.tokenSnapshotScopeSignature(for: .codex) + + #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 = 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")) + let clearResult = CostUsageCacheLocations.clearAllCostUsageCaches( + in: [artifactDirectory], + stateRoot: root) + #expect(clearResult.errorDescription == nil) + let generationIsCurrent = await store.tokenRefreshPublicationIsCurrent( + provider: .codex, + publicationGuard: generationGuard, + historyDays: historyDays, + costScopeSignature: scopeSignature) + #expect(!generationIsCurrent) + + let telemetryGuard = await 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) + let telemetryIsCurrent = await store.tokenRefreshPublicationIsCurrent( + provider: .codex, + publicationGuard: telemetryGuard, + historyDays: historyDays, + costScopeSignature: scopeSignature) + #expect(!telemetryIsCurrent) + + 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")) + let artifactIsCurrent = await store.tokenRefreshPublicationIsCurrent( + provider: .codex, + publicationGuard: artifactGuard, + historyDays: historyDays, + costScopeSignature: scopeSignature) + #expect(!artifactIsCurrent) + + let isolationGuard = await store.tokenRefreshPublicationGuard(for: .codex) + #expect(CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected(true, stateRoot: root)) + let isolationIsCurrent = await store.tokenRefreshPublicationIsCurrent( + provider: .codex, + publicationGuard: isolationGuard, + historyDays: historyDays, + costScopeSignature: scopeSignature) + #expect(!isolationIsCurrent) + } +} 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 new file mode 100644 index 0000000000..980cf31931 --- /dev/null +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCacheTests.swift @@ -0,0 +1,1637 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CLIProxyAPIUsageCacheTests { + @Test + func `credential interaction requirement preserves unknown stored settings`() { + let snapshot = CLIProxyAPIConnectionSettingsStore.storedSettingsSnapshot( + from: .interactionRequired) + + guard case .unavailable = snapshot else { + Issue.record("Interaction-required credentials must remain unavailable, not missing.") + return + } + } + + @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 + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-cleanup-\(UUID().uuidString)", 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] { + 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)) + } + for directory in [legacy, durable] { + let claudeCache = CostUsageClaudeCacheIO.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( + in: [legacy, durable], + stateRoot: root, + 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: CostUsageClaudeCacheIO.cacheFileURL( + provider: .claude, + cacheRoot: directory.deletingLastPathComponent()).path)) + } + #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 + 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], + stateRoot: root, + 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 `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 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.value) + #expect(didRollback.value) + } + + @Test + 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) + 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 generationWasPublishedAtStore = 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)) + generationWasPublishedAtStore.setValue( + CostUsageCacheLocations.cliProxyAPIConfigurationGeneration( + stateRoot: root, + fileManager: .default) != nil) + return true + }, + setDisconnectedState: { _ in true }, + restore: { _ in true })) + + #expect(saved) + #expect(artifactWasStagedAtStore.value) + #expect(generationWasPublishedAtStore.value) + #expect(!fileManager.fileExists(atPath: usageFile.path)) + } + + @Test + 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") + #expect(CLIProxyAPIConnectionSettingsStore.artifactDisposition( + existing, + isDisconnected: false, + storedSettings: .found(existing)) == .preserve) + + #expect(CLIProxyAPIConnectionSettingsStore.artifactDisposition( + replacement, + isDisconnected: false, + storedSettings: .found(existing)) == .purge) + } + + @Test + 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 disconnected = LockIsolated(false) + let wasIsolatedAtStore = LockIsolated(false) + + let saved = CLIProxyAPIConnectionSettingsStore.saveSerialized( + replacement, + artifactDirectories: [costUsage], + stateRoot: root, + fileManager: fileManager, + operations: .init( + isDisconnected: { disconnected.value }, + loadStored: { .found(existing) }, + 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)) + } + + @Test + 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(update == nil) + #expect(fileManager.fileExists(atPath: firstURL.path)) + #expect(fileManager.fileExists(atPath: secondURL.path)) + } + + @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( + isDisconnected: { false }, + loadStored: { .missing }, + store: { _ in true }, + setDisconnectedState: { _ in true }, + restore: { _ in true }))) + let savedGeneration = try #require(CostUsageCacheLocations.cliProxyAPIConfigurationGeneration( + stateRoot: root, + fileManager: fileManager)) + + #expect(CLIProxyAPIConnectionSettingsStore.removeAndPurgeTelemetry( + in: [], + stateRoot: root, + fileManager: fileManager, + 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)) + + #expect(removedGeneration != savedGeneration) + } + + @Test + 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) + defer { try? fileManager.removeItem(at: root) } + + #expect(!CLIProxyAPIConnectionSettingsStore.saveSerialized( + CLIProxyAPIConnectionSettings(managementKey: "test-management-key"), + stateRoot: root, + fileManager: fileManager, + operations: .init( + isDisconnected: { false }, + loadStored: { .missing }, + store: { _ in false }, + setDisconnectedState: { _ in true }, + restore: { _ in true }))) + #expect(CostUsageCacheLocations.cliProxyAPIConfigurationGeneration( + stateRoot: root, + fileManager: fileManager) != nil) + } +} + +extension CLIProxyAPIUsageCacheTests { + @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) + 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") + let storedSettings = LockIsolated(existing) + let disconnected = LockIsolated(true) + let didStore = LockIsolated(false) + let didRestore = 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 + didStore.setValue(true) + storedSettings.setValue(settings) + return true + }, + setDisconnectedState: { value in + disconnected.setValue(value) + return true + }, + restore: { snapshot in + didRestore.setValue(true) + guard case let .found(settings) = snapshot else { return false } + storedSettings.setValue(settings) + return true + })) + + #expect(!saved) + #expect(!didStore.value) + #expect(!didRestore.value) + #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) + 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], + stateRoot: root, + fileManager: fileManager, + operations: .init( + isDisconnected: { disconnected.value }, + loadStored: { .found(existing) }, + clearConfiguration: { + didClear.setValue(true) + return false + }, + 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(!didClear.value) + #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 + 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( + isDisconnected: { false }, + loadStored: { .missing }, + store: { _ in + saveEntered.signal() + releaseSave.wait() + return true + }, + setDisconnectedState: { _ in true }, + restore: { _ in true })) + } + #expect(await Self.waitForSignal(saveEntered, timeout: .now() + 1)) + + let removalTask = Task.detached { + CLIProxyAPIConnectionSettingsStore.removeAndPurgeTelemetry( + in: [costUsage], + stateRoot: root, + fileManager: .default, + operations: .init( + isDisconnected: { false }, + loadStored: { .missing }, + clearConfiguration: { + removalEntered.signal() + return true + }, + setDisconnectedState: { _ in true }, + restore: { _ in 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 + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-disconnected-collection-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: root) } + #expect(CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected( + true, + stateRoot: root, + fileManager: fileManager)) + + let result = await CLIProxyAPIUsageCollector.collect( + cacheRoot: root, + settings: CLIProxyAPIConnectionSettings(managementKey: "secret")) + + #expect(result == .notConfigured) + } + + @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 + 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)) + } + + @Test + 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) + 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 lockHolder = Task.detached { + try CostUsageCacheLocations.withCLIProxyAPIInterprocessLock(stateRoot: durableRoot) { + lockAcquired.signal() + releaseLock.wait() + #expect(CLIProxyAPIUsageCacheIO.merge( + [collectedRecord], + cacheRoot: durableRoot, + legacyCacheRoot: nil, + now: timestamp) == 1) + } + } + #expect(await Self.waitForSignal(lockAcquired, timeout: .now() + 1)) + + let loadTask = Task.detached { + loadStarted.signal() + let records = CLIProxyAPIUsageCacheIO.load( + cacheRoot: durableRoot, + legacyCacheRoot: legacyRoot, + now: timestamp) + 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 Set(loadTask.value.map(\.requestID)) == ["legacy", "collected"]) + let finalRecords = CLIProxyAPIUsageCacheIO.load( + cacheRoot: durableRoot, + legacyCacheRoot: legacyRoot, + now: timestamp) + #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)), + ] + let now = second.addingTimeInterval(1) + + #expect(CLIProxyAPIUsageCacheIO.merge( + records, + cacheRoot: root, + now: now) == 2) + #expect(CLIProxyAPIUsageCacheIO.merge( + records, + cacheRoot: root, + now: now) == 0) + + let roundTripped = CLIProxyAPIUsageCacheIO.load( + cacheRoot: root, + now: now) + #expect(roundTripped.count == 2) + #expect( + roundTripped.map { Int64($0.timestamp.timeIntervalSince1970 * 1000) } + == records.map { Int64($0.timestamp.timeIntervalSince1970 * 1000) }) + } + + @Test + 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).assigningNewLocalOccurrenceID(), + Self.record(id: "", timestamp: timestamp).assigningNewLocalOccurrenceID(), + ] + #expect(records[0].localOccurrenceID != records[1].localOccurrenceID) + + #expect(CLIProxyAPIUsageCacheIO.merge( + [records[0]], + cacheRoot: root, + now: timestamp) == 1) + #expect(CLIProxyAPIUsageCacheIO.merge( + [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.count == 2) + #expect(Set(roundTripped.compactMap(\.localOccurrenceID)) == Set(records.compactMap(\.localOccurrenceID))) + } + + @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 + let root = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-pending-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(CLIProxyAPIUsagePendingIO.save(records, pendingRoot: root)) + let roundTripped = try #require(CLIProxyAPIUsagePendingIO.load(pendingRoot: root)) + + #expect(roundTripped.count == 2) + #expect( + roundTripped.map { Int64($0.timestamp.timeIntervalSince1970 * 1000) } + == 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"]) + } + + @Test + 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) + 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(CLIProxyAPIUsageCacheIO.merge( + records, + cacheRoot: root, + now: records[1].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 { + 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)) + } + + 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) + } + } + } +} + +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 @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 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) + 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.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.addingTimeInterval(60)) + #expect(CLIProxyAPIUsagePendingIO.load(pendingRoot: root, now: now) == pending) + } + + @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 + 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 staged telemetry isolated for retry`() 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") }) + + let recovered = CostUsageCacheLocations.recoverCLIProxyAPIArtifactsTransaction( + stateRoot: root, + 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( + 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 + 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)) + } + + @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 { + @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 + 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, + 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) {} + + #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 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, + 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 false + }, + 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 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 + let root = fileManager.temporaryDirectory + .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) + 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, + 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 `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 `transaction owned isolation completes interrupted credential removal`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .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) + 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, + removalIsolationPublished: false, + removalCredentialsCleared: false)) + #expect(CostUsageCacheLocations.commitCLIProxyAPIConfigurationGenerationUpdate( + generationUpdate, + fileManager: fileManager)) + #expect(CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected( + true, + stateRoot: root, + fileManager: fileManager)) + #expect(CostUsageCacheLocations.markCLIProxyAPIArtifactsRemovalIsolationPublished( + artifactsUpdate, + 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/CLIProxyAPIUsageCollectorTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift new file mode 100644 index 0000000000..83e35b3318 --- /dev/null +++ b/Tests/CodexBarTests/CLIProxyAPIUsageCollectorTests.swift @@ -0,0 +1,628 @@ +import Foundation +import Testing +@testable import CodexBarCLI +@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 + } +} + +private final class CLIProxyAPIRedirectProofRecorder: @unchecked Sendable { + struct Snapshot: Sendable { + let originRequests: Int + let originAuthorizations: Int + let targetRequests: Int + let targetAuthorizations: Int + } + + 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() + } + + 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() + } + } + } +} + +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 + 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.5", + alias: "gpt-5.5", + 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 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: true) + let blockedCostUsageRoot = cacheRoot.appendingPathComponent("cost-usage", isDirectory: false) + let pendingRoot = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-pending-\(UUID().uuidString)", isDirectory: true) + 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) + } + 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: "", + 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, + pendingRoot: pendingRoot, + client: client) + + #expect(result == .failed("Could not save CLIProxyAPI usage telemetry.")) + let pendingOccurrenceID = try #require( + CLIProxyAPIUsagePendingIO.load(pendingRoot: pendingRoot)?.first?.localOccurrenceID) + #expect(!pendingOccurrenceID.isEmpty) + try fileManager.removeItem(at: blockedCostUsageRoot) + let retryClient = CLIProxyAPIUsageQueueClient( + settings: .init(managementKey: "management-secret"), + dataLoader: { request in + #expect(CLIProxyAPIUsageCacheIO.load(cacheRoot: cacheRoot).map(\.localOccurrenceID) == [ + pendingOccurrenceID, + ]) + 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, + pendingRoot: pendingRoot, + client: retryClient) + + #expect(retryResult == .collected(1)) + #expect(CLIProxyAPIUsageCacheIO.load(cacheRoot: cacheRoot).map(\.localOccurrenceID) == [pendingOccurrenceID]) + #expect(!fileManager.fileExists( + atPath: CLIProxyAPIUsagePendingIO.pendingFileURL(pendingRoot: pendingRoot).path)) + } + + @Test + func `does not merge a popped batch when staging fails`() async throws { + let fileManager = FileManager.default + let cacheRoot = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-cache-\(UUID().uuidString)", isDirectory: true) + let pendingRoot = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-blocked-pending-\(UUID().uuidString)", isDirectory: false) + try Data("not-a-directory".utf8).write(to: pendingRoot) + defer { + try? fileManager.removeItem(at: cacheRoot) + try? fileManager.removeItem(at: pendingRoot) + } + 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: "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, + pendingRoot: pendingRoot, + client: client) + + #expect(result == .failed("Could not stage CLIProxyAPI usage telemetry.")) + #expect(CLIProxyAPIUsageCacheIO.load(cacheRoot: cacheRoot).isEmpty) + #expect(!fileManager.fileExists( + atPath: CLIProxyAPIUsageCacheIO.cacheFileURL(cacheRoot: cacheRoot).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.5", + alias: "gpt-5.5", + 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) + } + + @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 + 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) + } + + @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 `credential interaction requirement remains retryable without prompting`() async { + let result = await CLIProxyAPIUsageCollector.collect( + settingsResult: .interactionRequired) + + #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 + 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 + let cacheRoot = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-mid-collection-disconnect-\(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.5", + alias: "gpt-5.5", + 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 configurationIsCurrent = LockIsolated(true) + let popCount = LockIsolated(0) + let client = CLIProxyAPIUsageQueueClient( + settings: .init(managementKey: "management-secret"), + dataLoader: { request in + popCount.setValue(popCount.value + 1) + configurationIsCurrent.setValue(false) + 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, + configurationIsCurrent: { configurationIsCurrent.value }, + client: client) + + #expect(result == .notConfigured) + #expect(popCount.value == 1) + #expect(CLIProxyAPIUsageCacheIO.load(cacheRoot: cacheRoot).count == 100) + } + + @Test + func `collector stages an in flight destructive pop before honoring cancellation`() async throws { + let fileManager = FileManager.default + let cacheRoot = fileManager.temporaryDirectory + .appendingPathComponent("cliproxy-cancelled-pop-\(UUID().uuidString)", isDirectory: true) + 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.5", + alias: "gpt-5.5", + endpoint: "POST /v1/messages", + authType: "oauth", + requestID: "request-in-flight", + tokens: .init(input: 10, output: 20, total: 30)) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode([record]) + let popStarted = DispatchSemaphore(value: 0) + let releasePop = DispatchSemaphore(value: 0) + let client = CLIProxyAPIUsageQueueClient( + settings: .init(managementKey: "management-secret"), + dataLoader: { request in + popStarted.signal() + _ = await Self.waitForSignal(releasePop, timeout: .distantFuture) + try Task.checkCancellation() + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + return (data, response) + }) + let collection = Task { + await CLIProxyAPIUsageCollector.collect( + cacheRoot: cacheRoot, + client: client) + } + #expect(await Self.waitForSignal(popStarted, timeout: .now() + 1)) + + collection.cancel() + releasePop.signal() + + #expect(await collection.value == .collected(1)) + #expect(CLIProxyAPIUsageCacheIO.load(cacheRoot: cacheRoot).map(\.requestID) == ["request-in-flight"]) + } + + 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/CLIProxyAPIUsageStoreTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift new file mode 100644 index 0000000000..907e236bc1 --- /dev/null +++ b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTests.swift @@ -0,0 +1,923 @@ +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) + } +} + +private actor CLIProxyAPIUsageCollectorCancellationRecorder { + private(set) var wasCancelled = false + + func recordCancellation() { + self.wasCancelled = true + } +} + +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 + 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) + } + + @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 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 collectorFinishedBeforePurge = false + store.publishTokenSnapshot(Self.tokenSnapshot(), for: .codex) + store.publishTokenSnapshot(Self.tokenSnapshot(), for: .claude) + let codexPublicationRevision = store.tokenSnapshotPublicationRevision(for: .codex) + let claudePublicationRevision = store.tokenSnapshotPublicationRevision(for: .claude) + let claudePublicationGuard = await store.tokenRefreshPublicationGuard(for: .claude) + let claudeScopeSignature = store.tokenSnapshotScopeSignature(for: .claude) + var refreshes: [(UsageProvider, Bool)] = [] + + 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) + #expect(collectorFinishedBeforePurge) + #expect(store.cliProxyAPIUsageCollectorTask == nil) + #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) + let claudePublicationIsCurrent = await store.tokenRefreshPublicationIsCurrent( + provider: .claude, + publicationGuard: claudePublicationGuard, + historyDays: settings.costUsageHistoryDays, + costScopeSignature: claudeScopeSignature) + #expect(!claudePublicationIsCurrent) + #expect(refreshes.map(\.0) == [.claude, .codex]) + #expect(refreshes.map(\.1) == [true, true]) + #expect(await recorder.wasCancelled) + } + + @Test + 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) + 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) + 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 + 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) + store.publishTokenSnapshot(Self.tokenSnapshot(), for: .codex) + var scheduledCleanupRetry = false + var refreshes: [(UsageProvider, Bool)] = [] + + let removed = await store.removeCLIProxyAPIConfiguration( + remove: { .telemetryCleanupFailed }, + 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 + 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 + func `collector maintenance retries failed recovery before the daily deadline`() 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 recorder = CLIProxyAPICleanupRetryRecorder() + + store.startCLIProxyAPIUsageCollector( + collectionInterval: .milliseconds(1), + pendingPruneInterval: .seconds(24 * 60 * 60), + failedPruneRetryInterval: .milliseconds(1), + maintenance: { recorder.attempt() }, + collector: { .disabled }) + for _ in 0..<100 where recorder.count < 2 { + try? await Task.sleep(for: .milliseconds(1)) + } + let task = store.stopCLIProxyAPIUsageCollector() + await task?.value + + #expect(recorder.count == 2) + } + + @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 `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 }, + publishAttributionIsolation: { _ in + Issue.record("Initial missing configuration must not publish disconnect isolation.") + return 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 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 + .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) + 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: { _ in + 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.tokenSnapshot(for: .claude) == nil) + #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( + SpendDashboardSource.configuration(settings: settings, store: store).sourceRevisions != + dashboardConfiguration.sourceRevisions) + + collectorState = await store.handleCLIProxyAPIUsageCollectionResult( + .notConfigured, + collectorState: collectorState, + isExplicitlyDisconnected: { false }, + publishAttributionIsolation: { _ in + isolationPublicationCount += 1 + return true + }, + 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(isolationPublicationCount == 1) + #expect(refreshes.map(\.0) == [.claude, .codex]) + refreshes.removeAll() + + collectorState = await store.handleCLIProxyAPIUsageCollectionResult( + .failed("temporary failure"), + collectorState: collectorState, + isExplicitlyDisconnected: { false }) + #expect(collectorState.configurationAvailability == .unavailable) + collectorState = await store.handleCLIProxyAPIUsageCollectionResult( + .collected(0), + collectorState: collectorState, + isExplicitlyDisconnected: { false }, + refresh: { provider, force in + refreshes.append((provider, force)) + }) + #expect(collectorState.configurationAvailability == .available) + #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: { _ in + 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: { _ in 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)") + 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 `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 `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 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 through a later disconnect`() 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") + #expect(collectorState.configurationTransitionPending) + + collectorState = await store.handleCLIProxyAPIUsageCollectionResult( + .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)) + }) + + #expect(collectorState.configurationAvailability == .unavailable) + #expect(!collectorState.configurationTransitionPending) + #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)") + 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( + 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( + configurationGeneration: "before-replacement"), + configurationGeneration: { "after-replacement" }, + telemetryRevision: { nil }) + + #expect(collectorState.configurationAvailability == .unavailable) + #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" }, + telemetryRevision: { nil }) + + #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)") + 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 (cleared: 0, errorMessage: nil) + }) + store.stopCLIProxyAPIUsageCollector() + + #expect(error == nil) + #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 (cleared: 0, errorMessage: 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 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)") + 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)) + } + + @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) + } + + 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 { + 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) + } + } +} diff --git a/Tests/CodexBarTests/CLIProxyAPIUsageStoreTransitionTests.swift b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTransitionTests.swift new file mode 100644 index 0000000000..96a8298a4b --- /dev/null +++ b/Tests/CodexBarTests/CLIProxyAPIUsageStoreTransitionTests.swift @@ -0,0 +1,94 @@ +import CodexBarCore +import Foundation +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)") + 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: { _ in + 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]) + } +} 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/CostUsageDailyReportMergeTests.swift b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift index 318da0e780..a842877aec 100644 --- a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift +++ b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift @@ -243,6 +243,56 @@ struct CostUsageDailyReportMergeTests { #expect(entry.coverageCounts.coverageRatio == 1) } + @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.5"], + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.5", + 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.5"], + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.5", + 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( @@ -261,6 +311,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, @@ -290,6 +345,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, @@ -317,6 +378,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, @@ -326,6 +393,51 @@ 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, + pricedRequestCount: 4), + ], + 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, + pricedRequestCount: 3), + ], + 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(entry.pricedRequestCount == 7) + #expect(merged.summary?.reasoningTokens == 12) + } + @Test func `merged report unions days and orders model breakdowns deterministically`() { let first = CostUsageDailyReport( @@ -369,6 +481,236 @@ 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) == [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 `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 = [ + 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( @@ -403,4 +745,68 @@ 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), + ], + pricedRequestCount: 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), + ], + pricedRequestCount: 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(entry.pricedRequestCount == 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) + } } 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) + } +} diff --git a/Tests/CodexBarTests/CostUsageFetcherCLIProxyConcurrencyTests.swift b/Tests/CodexBarTests/CostUsageFetcherCLIProxyConcurrencyTests.swift new file mode 100644 index 0000000000..5deac42160 --- /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, + model: String, + seconds: TimeInterval, + tokens: (input: Int, output: Int)) -> [String: Any] + { + [ + "type": "assistant", + "timestamp": env.isoString(for: day.addingTimeInterval(seconds)), + "sessionId": sessionID, + "requestId": requestID, + "message": [ + "id": "message-\(requestID)", + "model": model, + "usage": ["input_tokens": tokens.input, "output_tokens": tokens.output], + ], + ] + } + _ = try env.writeClaudeProjectFile( + relativePath: "concurrent-proxy/session.jsonl", + contents: env.jsonl([ + assistant( + sessionID: "session-codex", + requestID: "codex", + model: "gpt-5.5", + seconds: 0, + tokens: (input: 10, output: 2)), + assistant( + sessionID: "session-claude", + requestID: "claude", + model: "claude-sonnet-4-6", + seconds: 2, + 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, model, seconds) in [ + ("codex", "session-codex", "gpt-5.5", 0.0), + ("claude", "session-claude", "claude-sonnet-4-6", 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":"\(model)"} + === API RESPONSE === + """ + try Data(log.utf8).write(to: logs.appendingPathComponent("\(name).log")) + } + CLIProxyAPIUsageCacheIO.merge( + [ + CLIProxyAPIUsageRecord( + timestamp: day, + provider: "codex", + executorType: "CodexExecutor", + model: "gpt-5.5", + alias: "gpt-5.5", + endpoint: "/v1/messages", + authType: "oauth", + requestID: "cliproxy-codex", + tokens: .init(input: 10, output: 2, total: 12)), + CLIProxyAPIUsageRecord( + timestamp: day.addingTimeInterval(2), + 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: 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 == "claude") + } +} diff --git a/Tests/CodexBarTests/CostUsageFetcherCachedProxyDisconnectTests.swift b/Tests/CodexBarTests/CostUsageFetcherCachedProxyDisconnectTests.swift new file mode 100644 index 0000000000..965569c48e --- /dev/null +++ b/Tests/CodexBarTests/CostUsageFetcherCachedProxyDisconnectTests.swift @@ -0,0 +1,289 @@ +import Foundation +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(CostUsageClaudeCacheIO.load( + provider: .claude, + cacheRoot: env.cacheRoot, + 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]]] + _ = try CostUsageClaudeCacheIO.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() + 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", 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() } + + 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": model, + "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":"\(model)"} + === 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: model, + 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 == (shouldRemainInClaude ? 105 : nil)) + #expect(disconnectedClaude.daily.first?.modelBreakdowns?.first?.attribution?.route != .cliProxyAPI) + let cachedCodex = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + scannerOptions: options) + #expect(cachedCodex?.daily.isEmpty == true) + #expect(cachedCodex?.last30DaysTokens == 0) + } +} diff --git a/Tests/CodexBarTests/CostUsageFetcherCachedProxyTimeZoneTests.swift b/Tests/CodexBarTests/CostUsageFetcherCachedProxyTimeZoneTests.swift new file mode 100644 index 0000000000..599630a070 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageFetcherCachedProxyTimeZoneTests.swift @@ -0,0 +1,179 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CostUsageFetcherCachedProxyTimeZoneTests { + @Test + 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().scannerOptions(calendar: calendar)) + + #expect(options.calendar.timeZone == calendar.timeZone) + #expect(options.cliProxyAPIHome?.lastPathComponent == ".cli-proxy-api") + } + + @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() + 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) + _ = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + allowPricingRefresh: false, + includePiSessions: false, + scannerOptions: options) + + let claudeCache = CostUsageClaudeCacheIO.load(provider: .claude, cacheRoot: env.cacheRoot) + #expect(!claudeCache.days.isEmpty) + var staleZoneCalendar = options.calendar + staleZoneCalendar.timeZone = try #require( + ["UTC", "Asia/Bangkok"] + .compactMap(TimeZone.init(identifier:)) + .first { $0.identifier != options.calendar.timeZone.identifier }) + _ = try CostUsageClaudeCacheIO.save( + provider: .claude, + cache: claudeCache, + cacheRoot: env.cacheRoot, + calendar: staleZoneCalendar) + + let cachedSnapshot = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + scannerOptions: options) + + #expect(cachedSnapshot?.daily.isEmpty == true) + #expect(cachedSnapshot?.last30DaysTokens == 0) + } +} diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index 8acbe43282..7483a6b543 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -2,8 +2,21 @@ import Foundation import Testing @testable import CodexBarCore +// swiftlint:disable file_length + @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() @@ -881,8 +894,624 @@ 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), + ]) + } + + @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": "claude-sonnet-4-6", + "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)), + "sessionId": "session-proxy", + "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":"claude-sonnet-4-6"} + === API RESPONSE === + """ + try Data(proxyLog.utf8).write(to: cliProxyLogs.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: "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 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) + 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 cachedScopedCodex = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + codexHomePath: scopedHome.path, + allowScopedCodexHome: true, + includePiSessions: false, + includeProjectAndSessionBreakdowns: false, + scannerOptions: options) + + let expectedCodexCost = try #require(CostUsagePricing.claudeProxyCodexCostUSD( + model: "gpt-5.5", + 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 == "claude-sonnet-4-6") + #expect(codexBreakdown.attribution == CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .anthropic, + upstream: .init( + provider: "codex", + authType: .oauth, + model: "gpt-5.5", + 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"]) + #expect(claude.daily.first?.modelBreakdowns?.first?.attribution == nil) + + #expect(cachedCodex.daily.first?.totalTokens == 135) + #expect(cachedCodex.daily.first?.modelBreakdowns?.first?.attribution == codexBreakdown.attribution) + #expect(cachedScopedCodex?.daily.isEmpty == true) + #expect(cachedScopedCodex?.last30DaysTokens == 0) + + 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 + 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.5", + "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.5"} + === 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 `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 `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 `explicit disconnect keeps proxy routed usage 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": "claude-sonnet-4-6", + "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","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: disconnected-proxy-session + === REQUEST BODY === + {"model":"claude-sonnet-4-6"} + === 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) + let cachedCodex = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + scannerOptions: options) + + #expect(claude.daily.first?.totalTokens == 105) + #expect(claude.daily.first?.modelBreakdowns?.first?.attribution == nil) + #expect(codex.daily.isEmpty) + #expect(cachedCodex?.daily.isEmpty == true) + #expect(cachedCodex?.last30DaysTokens == 0) + } + + @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.5", + "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) + #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.5", + "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.5"} + === 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 request log does not cover a distant resumed turn`() 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.5", + "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.5"} + === 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 == 105) + #expect(codex.daily.first?.modelBreakdowns?.count == 1) + #expect(breakdown.modelName == "gpt-5.5") + #expect(breakdown.attribution?.route == .cliProxyAPI) + #expect(breakdown.attribution?.upstream == .init( + provider: "codex", + authType: .oauth, + model: "gpt-5.5")) + #expect(breakdown.attribution?.evidence.contains(.cliProxyAuthInventory) == true) + #expect(claude.daily.isEmpty) + } + + @Test + func `claude report preserves only anthropic 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" }) + #expect(claude.attribution?.route == .cliProxyAPI) + #expect(claude.attribution?.upstream?.provider == "claude") + #expect(claude.attribution?.upstream?.authType == .oauth) + #expect(breakdowns.contains { $0.modelName == "gemini-3-pro" } == false) } @Test diff --git a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift index 7f0e20fea5..f4d6565ba1 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() @@ -68,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] = [ @@ -97,9 +119,149 @@ 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 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 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() + 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 @@ -139,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() @@ -274,6 +448,215 @@ struct CostUsageFetcherUnknownModelPricingTests { #expect(await counter.requestCount == 0) } + @Test + 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": { + "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": "\(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) + 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":"\(alias)"} + === 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: alias, + 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 == alias) + #expect(breakdown.attribution?.upstream?.model == "gpt-new") + #expect(abs((breakdown.costUSD ?? 0) - 0.00028) < 0.0000001) + } + + @Test(arguments: [ + ("claude-new", "claude", "ClaudeExecutor", 0.00045, true), + ("gpt-new", "openrouter", "OpenAICompatExecutor", 0.00028, false), + ("gemma-new", "gemini", nil, 0.00036, false), + ]) + func `claude fetch retains only anthropic proxy upstreams`( + upstreamModel: String, + upstreamProvider: String, + executorType: String?, + expectedCost: Double, + shouldInclude: Bool) 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: upstreamProvider, + executorType: executorType, + model: upstreamModel, + alias: alias, + endpoint: "/v1/messages", + authType: "api_key", + requestID: "cliproxy-openrouter-\(upstreamModel)", + 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 } } } + }, + "google": { + "id": "google", + "models": { "gemma-new": { "id": "gemma-new", "cost": { "input": 2.5, "output": 11 } } } + } + } + """.utf8) + + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .claude, + now: day, + refreshPricingInBackground: false, + includePiSessions: false, + scannerOptions: options, + 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) + #expect(breakdown.attribution?.upstream?.model == upstreamModel) + #expect(abs((breakdown.costUSD ?? 0) - expectedCost) < 0.0000001) + } + @Test func `foreground pricing scheduling still requests a catalog for native plus pi usage`() async throws { let fixture = try UnknownModelPricingFixture() 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/CostUsageScannerClaudeFableTests.swift b/Tests/CodexBarTests/CostUsageScannerClaudeFableTests.swift index 74bde498ae..caa58de756 100644 --- a/Tests/CodexBarTests/CostUsageScannerClaudeFableTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerClaudeFableTests.swift @@ -44,6 +44,142 @@ 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 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) + #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 func `claude transcript refusal remains priced without billing provenance`() throws { let env = try CostUsageTestEnvironment() @@ -382,6 +518,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 = """ { diff --git a/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift b/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift index 7c90f7e9d2..82705a05c4 100644 --- a/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerClaudeMemoTests.swift @@ -23,6 +23,194 @@ 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 `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 `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 `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() + 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)) + + #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(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() diff --git a/Tests/CodexBarTests/ModelsDevPricingTests.swift b/Tests/CodexBarTests/ModelsDevPricingTests.swift index 9d9c156dec..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() @@ -52,6 +67,75 @@ 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 `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/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/PiSessionCostCompatibilityTests.swift b/Tests/CodexBarTests/PiSessionCostCompatibilityTests.swift index eca833a6df..80d82952a1 100644 --- a/Tests/CodexBarTests/PiSessionCostCompatibilityTests.swift +++ b/Tests/CodexBarTests/PiSessionCostCompatibilityTests.swift @@ -18,8 +18,9 @@ struct PiSessionCostCompatibilityTests { @Test( arguments: [false, true], [ - "c6c46a376ba16304", "55f640e6bb0ccba4", "21f10143afe00c55", "f8577be489f4c13d", "494eee446bb2e5f9", - "7e293e8fc9e25700", "e0b0319de43e22d7", + "0328e62ed77ec86e", "de5312de32fb485c", "18fb20b269764e46", "f8577be489f4c13d", "21f10143afe00c55", + "4ef786808187969b", "c6c46a376ba16304", "55f640e6bb0ccba4", "494eee446bb2e5f9", "7e293e8fc9e25700", + "e0b0319de43e22d7", ]) func `parser changes reprice pi and omp while current caches preserve independent invalidation`( catalogPresent: Bool, predecessorHash: String) throws diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index a9d6631cfa..eefa3544b2 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -910,58 +910,40 @@ struct ProviderArchitectureGatekeeperTests { reason: "This observation touchpoint reads a fixed provider field so UI invalidation tracks that setting."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 432, + line: 474, 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: 434, + line: 476, 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: 520, + line: 593, 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: 523, + line: 596, 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: 659, 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: 647, + line: 786, 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: 1693, - 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: 1722, - 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: 1739, - 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, @@ -1018,43 +1000,49 @@ struct ProviderArchitectureGatekeeperTests { reason: "This provider-owned integration passes its fixed identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+CodexCostCatchUp.swift", - line: 28, + line: 29, anchor: "let scope = self.tokenCostScope(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: 29, + line: 30, anchor: "let scopeSignature = self.tokenSnapshotScopeSignature(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: 55, - anchor: "providerConfigRevision: self.settings.providerConfigRevision(for: .codex),", + line: 53, + 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: 284, + line: 290, 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: 287, + line: 293, 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: 325, + line: 329, + 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: 342, 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: 326, + line: 343, anchor: "&& self.isEnabled(.codex)", expectedProviderIDs: ["codex"], reason: "This provider-owned integration passes its fixed identity to a shared helper."), @@ -1126,31 +1114,37 @@ struct ProviderArchitectureGatekeeperTests { reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift", - line: 26, + line: 27, 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: 27, + line: 28, anchor: "self.isEnabled(.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: 92, - anchor: "providerConfigRevision: self.settings.providerConfigRevision(for: .codex),", + line: 88, + 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: 309, + line: 310, + 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: 326, 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: 310, + line: 327, anchor: "&& self.isEnabled(.codex)", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), @@ -1216,68 +1210,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: 92, + line: 106, 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: 261, + 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: 263, + 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: 264, + 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: 266, + 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: 267, + 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: 297, + 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: 298, + 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: 307, + 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: 407, + 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: 422, + 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."), @@ -1295,19 +1289,19 @@ struct ProviderArchitectureGatekeeperTests { reason: "Claude widget quota ownership uses the selected Claude account's isolated snapshot key."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore.swift", - line: 1070, + line: 1078, anchor: "provider: .deepseek,", expectedProviderIDs: ["deepseek"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore.swift", - line: 1172, + line: 1180, anchor: "let sourceMode = self.sourceMode(for: .claude)", expectedProviderIDs: ["claude"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore.swift", - line: 1176, + line: 1184, anchor: "provider: .claude,", expectedProviderIDs: ["claude"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), @@ -1361,31 +1355,55 @@ struct ProviderArchitectureGatekeeperTests { reason: "This provider-owned integration passes its fixed identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 307, + line: 337, + 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: 363, + 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: 371, + 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: 379, 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: 324, + line: 395, 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: 819, + line: 424, 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: 897, + line: 990, 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: 983, + line: 1069, + 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: 1175, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), @@ -1709,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: 1115, + line: 1145, anchor: "let projects = provider == .codex ? snapshot.projects : []", expectedProviderIDs: ["codex"], expectedReferenceCount: 2, @@ -2248,20 +2266,12 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/PreferencesSpendDashboardPane.swift", - line: 352, + line: 413, anchor: "self.configuration.providerIDs.contains(UsageProvider.codex.rawValue)", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, expectedReferenceFingerprint: ["codex@0"], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), - AllowedProviderConstruct( - path: "Sources/CodexBar/PreferencesSpendDashboardPane.swift", - line: 513, - anchor: ".count { $0.provider == .codex }", - expectedProviderIDs: ["codex"], - expectedReferenceCount: 3, - expectedReferenceFingerprint: ["codex@0", "codex@3", "codex@10"], - reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/Providers/Shared/ProviderTokenAccountSelection.swift", line: 28, @@ -2353,7 +2363,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 636, + line: 775, anchor: "(providers.contains(.codex) && settings.codexLocalSessionCostLedgerEnabled)", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2361,7 +2371,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: 176, + line: 200, anchor: "let codexSources = providers.contains(.codex)", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2369,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: 233, + line: 267, anchor: "let providerBaselines = initialProviders.filter { $0 != .codex }.map { provider in", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2377,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: 269, + line: 303, anchor: "let codexSources = providers.contains(.codex)", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2385,7 +2395,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: 292, + line: 326, anchor: "for provider in providers where provider != .codex {", expectedProviderIDs: ["codex", "grok"], expectedReferenceCount: 7, @@ -2393,7 +2403,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: 698, + line: 837, anchor: "if providers.contains(.codex) {", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2401,23 +2411,15 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 726, + line: 865, anchor: "if providers.contains(.codex) {", expectedProviderIDs: ["codex"], 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: 1766, - 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: 122, + line: 132, anchor: "guard summary.input.provider == .codex else { return false }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2425,7 +2427,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: 1093, + line: 1103, anchor: "guard provider == .mistral || provider == .openrouter || provider == .xai else { return displayCalendar }", expectedProviderIDs: ["mistral", "openrouter", "xai"], expectedReferenceCount: 3, @@ -2657,7 +2659,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: 23, + line: 24, anchor: "guard provider == .codex else { return }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2665,7 +2667,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: 322, + line: 339, anchor: "&& self.settings.providerConfigRevision(for: .codex) == context.providerConfigRevision", expectedProviderIDs: ["codex"], expectedReferenceCount: 3, @@ -3010,7 +3012,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift", - line: 56, + line: 57, anchor: "self.settings.isCostUsageEffectivelyEnabled(for: .codex),", expectedProviderIDs: ["codex"], expectedReferenceCount: 2, @@ -3018,7 +3020,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift", - line: 306, + line: 323, anchor: "&& self.settings.providerConfigRevision(for: .codex) == context.providerConfigRevision", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3151,7 +3153,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: 270, + line: 198, + anchor: "guard provider == .codex || provider == .claude else { return true }", + expectedProviderIDs: ["claude", "codex"], + 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: 354, anchor: "guard self.tokenSnapshotPublicationForCurrentProviderConfig(for: .codex) == nil else { return }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3159,25 +3169,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: 294, + line: 378, 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@9", - "codex@14", - "codex@23", - "codex@24", + "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: 331, + line: 418, anchor: "return provider == .codex && self.codexCostCatchUpActivity?.phase == .indexing", expectedProviderIDs: ["claude", "codex", "vertexai"], expectedReferenceCount: 4, @@ -3185,7 +3196,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: 396, + line: 483, anchor: "guard provider == .cursor else {", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3193,7 +3204,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: 555, anchor: "if provider == .cursor,", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3201,7 +3212,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: 483, + line: 576, anchor: "guard provider == .cursor,", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3209,7 +3220,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: 505, + line: 598, anchor: "case .openai:", expectedProviderIDs: ["grok", "mistral", "openai", "opencodego", "openrouter", "xai"], expectedReferenceCount: 12, @@ -3230,7 +3241,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: 574, + line: 680, anchor: "self.tokenFailureGates[.codex]?.reset()", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 2, @@ -3351,7 +3362,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 611, + line: 617, anchor: "self.metadata(for: .codex).browserCookieOrder ?? Browser.defaultImportOrder", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3359,7 +3370,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 663, + line: 669, anchor: "self.providerSpecs[provider]?.style ?? .codex", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3367,7 +3378,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 696, + line: 702, anchor: "guard provider != .codex else { return true }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3375,7 +3386,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1044, + line: 1052, anchor: "let claudeDebugConfiguration: ClaudeDebugLogConfiguration? = if provider == .claude {", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3383,7 +3394,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1067, + line: 1075, anchor: "let deepSeekHasTokenAccount = self.settings.selectedTokenAccount(for: .deepseek) != nil", expectedProviderIDs: ["deepseek"], expectedReferenceCount: 1, @@ -3391,7 +3402,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1124, + line: 1132, anchor: "case .amp:", expectedProviderIDs: ["amp", "deepseek", "notion", "ollama", "warp"], expectedReferenceCount: 7, @@ -3407,7 +3418,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1179, + line: 1187, anchor: "let claudeSettings = snapshot.claude ?? ProviderSettingsSnapshot.ClaudeProviderSettings(", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3519,7 +3530,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: 624, + line: 770, anchor: "if provider == .codex {", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3527,15 +3538,15 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 648, - anchor: "provider == .claude || (provider == .codex && options.shouldMergePiUsage)", + line: 803, + anchor: "provider == .claude || (provider == .codex && options.shouldMergeGlobalCodexUsage)", expectedProviderIDs: ["claude", "codex"], - expectedReferenceCount: 5, - expectedReferenceFingerprint: ["claude@0", "codex@0", "codex@10", "codex@15", "codex@27"], + expectedReferenceCount: 4, + expectedReferenceFingerprint: ["claude@0", "codex@0", "codex@10", "codex@15"], reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 695, + line: 853, anchor: "options.provider == .codex || options.provider == .claude", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 2, @@ -3543,15 +3554,15 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 726, + line: 884, anchor: "guard provider == .codex || provider == .claude else { return nil }", expectedProviderIDs: ["claude", "codex"], - expectedReferenceCount: 3, - expectedReferenceFingerprint: ["claude@0", "codex@0", "codex@5"], + expectedReferenceCount: 2, + expectedReferenceFingerprint: ["claude@0", "codex@0"], reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 1394, + line: 1845, anchor: "if provider == .vertexai {", expectedProviderIDs: ["claude", "vertexai"], expectedReferenceCount: 2, @@ -3559,7 +3570,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: 1750, + line: 2235, anchor: "if provider == .cursor {", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3746,7 +3757,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift", - line: 222, + line: 223, anchor: "guard let pricing = self.codex[model] else { continue }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3754,7 +3765,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: 452, + line: 453, anchor: "static let codexModelsDevProviderID = \"openai\"", expectedProviderIDs: ["deepseek", "openai", "opencode"], expectedReferenceCount: 4, @@ -3762,7 +3773,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: 487, + line: 488, anchor: "providerIDs.append(\"opencode\")", expectedProviderIDs: ["opencode"], expectedReferenceCount: 1, @@ -3770,7 +3781,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: 520, + line: 521, anchor: "if self.codex[trimmed] != nil {", expectedProviderIDs: ["codex"], expectedReferenceCount: 2, @@ -3778,7 +3789,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: 563, + line: 585, anchor: "if self.claude[base] != nil {", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3786,7 +3797,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: 596, + line: 618, anchor: "let bundled = lookup.pricing.providerID == self.codexModelsDevProviderID ? self.codex[key] : nil", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3794,7 +3805,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: 630, + line: 652, anchor: "guard let pricing = self.codex[key] else { return nil }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3802,7 +3813,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: 780, + line: 827, anchor: "guard let pricing = self.claude[key] else { return nil }", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, diff --git a/Tests/CodexBarTests/ShareStatsTests.swift b/Tests/CodexBarTests/ShareStatsTests.swift index 62db597364..e017a76f39 100644 --- a/Tests/CodexBarTests/ShareStatsTests.swift +++ b/Tests/CodexBarTests/ShareStatsTests.swift @@ -206,6 +206,88 @@ 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 `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: [ diff --git a/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift b/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift new file mode 100644 index 0000000000..66ef915108 --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardCodexProxySourceTests.swift @@ -0,0 +1,312 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@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) + 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(result.inputs.first { $0.id == SpendDashboardSource.codexProxySourceID }?.sourceKind == .cliProxyAPI) + #expect(proxyContexts.count == 1) + #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]) + } + + @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) + 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) + 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", + 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), + ] + + 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) + 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, + displayName: "Codex · CLIProxyAPI", + modelProviderName: "Codex", + snapshot: Self.snapshot(cost: 2, now: now)) + let request = SpendDashboardLoadRequest( + configuration: SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: ["\(account.id)|\(account.cacheIdentity)"]), + capturedInputs: [staleProxyInput], + unavailableSourceIDs: [], + codexRequests: [account], + now: now, + force: false) + let accountSnapshot = Self.snapshot(cost: 1, now: now) + + let result = await SpendDashboardSource.load( + request, + codexSnapshotLoader: { _ in accountSnapshot }, + codexProxySnapshotLoader: { _ in throw CancellationError() }) + + #expect(result.inputs.map(\.id) == ["codex:first"]) + #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", + inputTokens: nil, + outputTokens: nil, + totalTokens: 10, + costUSD: cost, + modelsUsed: ["gpt-5.5"], + 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) + } +} 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 { 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") diff --git a/Tests/CodexBarTests/SpendDashboardProxyAttributionTests.swift b/Tests/CodexBarTests/SpendDashboardProxyAttributionTests.swift new file mode 100644 index 0000000000..df71b9578c --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardProxyAttributionTests.swift @@ -0,0 +1,211 @@ +import CodexBarCore +import Foundation +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 `interaction-required proxy configuration preserves the presentation`() { + let presentation = spendDashboardCLIProxyAPIConfigurationPresentation( + loadResult: .interactionRequired, + 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( + 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.5"], + modelBreakdowns: [ + .init( + modelName: "gpt-5.5", + 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") + } + + @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.5"), + evidence: [.cliProxyAuthInventory, .cliProxyRequestLog, .modelProvider]) + let telemetryAttribution = CostUsageAttribution( + client: .claudeCode, + route: .cliProxyAPI, + modelProvider: .openAI, + upstream: .init( + provider: "codex", + authType: .oauth, + model: "openai/gpt-5.5", + 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.5"], + modelBreakdowns: [ + .init( + modelName: "gpt-5.5", + costUSD: 1, + totalTokens: 100, + attribution: inventoryAttribution), + .init( + modelName: "gpt-5.5", + 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(rows.map(\.attribution) == [telemetryAttribution, inventoryAttribution]) + #expect(rows.map(\.rank) == [1, 2]) + } +} diff --git a/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift b/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift index 2b2e996f97..62b7f2dfeb 100644 --- a/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift +++ b/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift @@ -122,6 +122,48 @@ struct SpendDashboardSourceConcurrencyTests { #expect(controller.failedSourceCount == 2) } + @Test + func `proxy generation change does not retain failed prior owner`() async throws { + 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() + defer { gate.close() } + let controller = SpendDashboardController( + requestBuilder: { mode in await requestSequence.next(mode: mode) }, + loader: { request in await gate.load(request) }) + + controller.update(configuration: initial) + try await gate.waitForPendingCount(1) + gate.resume(result: SpendDashboardLoadResult( + inputs: [Self.input(id: SpendDashboardSource.codexProxySourceID, cost: 5)], + failedSourceIDs: [])) + try await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 5) + + controller.update(configuration: replacement) + try await gate.waitForPendingCount(1) + #expect(controller.model.groups.isEmpty) + gate.resume(result: SpendDashboardLoadResult( + inputs: [], + failedSourceIDs: [SpendDashboardSource.codexProxySourceID])) + try 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() diff --git a/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift b/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift index b73e79388f..ef303447f6 100644 --- a/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift +++ b/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift @@ -283,6 +283,115 @@ 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) + } + + @Test + func `disconnect isolation 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() + #expect(CostUsageCacheLocations.setCLIProxyAPIExplicitlyDisconnected( + true, + stateRoot: env.cacheRoot)) + await gate.release() + await hydration?.value + + #expect(store.tokenSnapshot(for: .codex) == nil) + #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)! diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index 3460575d32..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,17 +163,16 @@ struct UsageStoreCoverageTests { historyDays: 30, source: .auto, credentialFingerprint: "unresolved") - let revision = store.providerPublicationRevision(for: .cursor) - let providerConfigRevision = settings.providerConfigRevision(for: .cursor) + let publicationGuard = await store.tokenRefreshPublicationGuard(for: .cursor) settings.costUsageHistoryDays = 7 - #expect(!store.tokenRefreshPublicationIsCurrent( + let publicationIsCurrent = await store.tokenRefreshPublicationIsCurrent( provider: .cursor, - publicationRevision: revision, - providerConfigRevision: providerConfigRevision, + publicationGuard: publicationGuard, historyDays: 30, costScopeSignature: initialSignature, - fetchedCredentialScopeFingerprint: fingerprint)) + fetchedCredentialScopeFingerprint: fingerprint) + #expect(!publicationIsCurrent) } @Test