diff --git a/Sources/CodexBar/Providers/OpenAI/OpenAIAPIProviderImplementation.swift b/Sources/CodexBar/Providers/OpenAI/OpenAIAPIProviderImplementation.swift index 738c26eece..4d13b22be6 100644 --- a/Sources/CodexBar/Providers/OpenAI/OpenAIAPIProviderImplementation.swift +++ b/Sources/CodexBar/Providers/OpenAI/OpenAIAPIProviderImplementation.swift @@ -15,6 +15,7 @@ struct OpenAIAPIProviderImplementation: ProviderImplementation { @MainActor func observeSettings(_ settings: SettingsStore) { _ = settings.openAIAPIKey + _ = settings.openAIAPIProjectID } @MainActor @@ -52,6 +53,28 @@ struct OpenAIAPIProviderImplementation: ProviderImplementation { ], isVisible: nil, onActivate: nil), + ProviderSettingsFieldDescriptor( + id: "openai-project-id", + title: "Project ID", + subtitle: "Optional. Applies to the configured Admin API key; selected token accounts do not " + + "inherit OPENAI_PROJECT_ID.", + kind: .plain, + placeholder: "proj_...", + binding: context.stringBinding(\.openAIAPIProjectID), + actions: [ + ProviderSettingsActionDescriptor( + id: "openai-open-projects", + title: "Open projects", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://platform.openai.com/settings/organization/projects") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: nil, + onActivate: nil), ] } } diff --git a/Sources/CodexBar/Providers/OpenAI/OpenAIAPISettingsStore.swift b/Sources/CodexBar/Providers/OpenAI/OpenAIAPISettingsStore.swift index b293a75bda..7dfb7edf68 100644 --- a/Sources/CodexBar/Providers/OpenAI/OpenAIAPISettingsStore.swift +++ b/Sources/CodexBar/Providers/OpenAI/OpenAIAPISettingsStore.swift @@ -11,4 +11,14 @@ extension SettingsStore { self.logSecretUpdate(provider: .openai, field: "apiKey", value: newValue) } } + + var openAIAPIProjectID: String { + get { self.configSnapshot.providerConfig(for: .openai)?.sanitizedWorkspaceID ?? "" } + set { + self.updateProviderConfig(provider: .openai) { entry in + entry.workspaceID = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .openai, field: "projectID", value: newValue) + } + } } diff --git a/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift index e1ca7baef4..dd692011dd 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift @@ -28,6 +28,14 @@ public struct CodexBarConfigIssue: Codable, Sendable, Equatable { } public enum CodexBarConfigValidator { + private static let workspaceIDProviders: [UsageProvider] = [ + .azureopenai, + .openai, + .opencode, + .opencodego, + .deepgram, + ] + public static func validate(_ config: CodexBarConfig) -> [CodexBarConfigIssue] { var issues: [CodexBarConfigIssue] = [] @@ -138,8 +146,7 @@ public enum CodexBarConfigValidator { provider: provider, field: "workspaceID", code: "workspace_unused", - message: "workspaceID is set but only azureopenai, opencode, opencodego, and deepgram support " + - "workspaceID.")) + message: "workspaceID is set but only \(self.workspaceIDProviderList) support workspaceID.")) } if let enterpriseHost = entry.enterpriseHost, @@ -183,12 +190,18 @@ public enum CodexBarConfigValidator { } private static func providerSupportsWorkspaceID(_ provider: UsageProvider) -> Bool { - switch provider { - case .azureopenai, .opencode, .opencodego, .deepgram: - true - default: - false - } + self.workspaceIDProviders.contains(provider) + } + + private static var workspaceIDProviderList: String { + self.formattedProviderList(self.workspaceIDProviders) + } + + private static func formattedProviderList(_ providers: [UsageProvider]) -> String { + let names = providers.map(\.rawValue) + guard let last = names.last else { return "" } + guard names.count > 1 else { return last } + return "\(names.dropLast().joined(separator: ", ")), and \(last)" } private static func providerSupportsEnterpriseHost(_ provider: UsageProvider) -> Bool { diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift index 49dcbd0ba9..33d85f66cc 100644 --- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift +++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift @@ -6,6 +6,9 @@ public enum ProviderConfigEnvironment { provider: UsageProvider, config: ProviderConfig?) -> [String: String] { + if provider == .openai { + return self.applyOpenAIOverrides(base: base, config: config) + } if provider == .bedrock { return self.applyBedrockOverrides(base: base, config: config) } @@ -110,6 +113,21 @@ public enum ProviderConfigEnvironment { } } + private static func applyOpenAIOverrides( + base: [String: String], + config: ProviderConfig?) -> [String: String] + { + guard let config else { return base } + var env = base + if let apiKey = config.sanitizedAPIKey { + env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] = apiKey + } + if let projectID = config.sanitizedWorkspaceID { + env[OpenAIAPISettingsReader.projectIDEnvironmentKey] = projectID + } + return env + } + private static func applyBedrockOverrides( base: [String: String], config: ProviderConfig?) -> [String: String] diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIProviderDescriptor.swift index eadd2539f3..532ae01744 100644 --- a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIProviderDescriptor.swift @@ -43,13 +43,12 @@ public enum OpenAIAPIProviderDescriptor { struct OpenAIAPIBalanceFetchStrategy: ProviderFetchStrategy { let id: String = "openai.api.balance" let kind: ProviderFetchKind = .apiToken - let usageFetcher: @Sendable (String, Int) async throws -> OpenAIAPIUsageSnapshot + let usageFetcher: @Sendable (OpenAIAPIUsageCredential, Int) async throws -> OpenAIAPIUsageSnapshot let balanceFetcher: @Sendable (String) async throws -> OpenAIAPICreditBalanceSnapshot init( - usageFetcher: @escaping @Sendable (String, Int) async throws -> OpenAIAPIUsageSnapshot = { apiKey, days in - try await OpenAIAPIUsageFetcher.fetchUsage(apiKey: apiKey, historyDays: days) - }, + usageFetcher: @escaping @Sendable (OpenAIAPIUsageCredential, Int) async throws -> OpenAIAPIUsageSnapshot = + OpenAIAPIBalanceFetchStrategy.fetchUsage(credential:days:), balanceFetcher: @escaping @Sendable (String) async throws -> OpenAIAPICreditBalanceSnapshot = { apiKey in try await OpenAIAPICreditBalanceFetcher.fetchBalance(apiKey: apiKey) }) @@ -59,24 +58,27 @@ struct OpenAIAPIBalanceFetchStrategy: ProviderFetchStrategy { } func isAvailable(_ context: ProviderFetchContext) async -> Bool { - Self.resolveToken(environment: context.env) != nil + OpenAIAPIUsageCredential(environment: context.env) != nil } func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { - guard let apiKey = Self.resolveToken(environment: context.env) else { + guard let credential = OpenAIAPIUsageCredential(environment: context.env) else { throw OpenAIAPISettingsError.missingToken } do { - let usage = try await self.usageFetcher(apiKey, context.costUsageHistoryDays) + let usage = try await self.usageFetcher(credential, context.costUsageHistoryDays) return self.makeResult( usage: usage.toUsageSnapshot(), - sourceLabel: "admin-api") + sourceLabel: credential.sourceLabel) } catch { let usageError = error - // Preserve the older balance-only path for project/user keys and admin API outages. + if !credential.allowsLegacyBalanceFallback { + throw usageError + } + // Preserve the older balance-only path for unscoped keys and Admin API outages. do { - let balance = try await self.balanceFetcher(apiKey) + let balance = try await self.balanceFetcher(credential.apiKey) return self.makeResult( usage: balance.toUsageSnapshot(), sourceLabel: "billing-api") @@ -93,7 +95,40 @@ struct OpenAIAPIBalanceFetchStrategy: ProviderFetchStrategy { false } - private static func resolveToken(environment: [String: String]) -> String? { - ProviderTokenResolver.openAIAPIToken(environment: environment) + private static func fetchUsage( + credential: OpenAIAPIUsageCredential, + days: Int) async throws -> OpenAIAPIUsageSnapshot + { + try await OpenAIAPIUsageFetcher.fetchUsage( + apiKey: credential.apiKey, + projectID: credential.projectID, + historyDays: days) + } +} + +struct OpenAIAPIUsageCredential: Equatable, Sendable { + let apiKey: String + let projectID: String? + let usesAdminKey: Bool + + init?(environment: [String: String]) { + if let adminKey = OpenAIAPISettingsReader.adminAPIKey(environment: environment) { + self.apiKey = adminKey + self.usesAdminKey = true + } else if let apiKey = OpenAIAPISettingsReader.apiKey(environment: environment) { + self.apiKey = apiKey + self.usesAdminKey = false + } else { + return nil + } + self.projectID = OpenAIAPISettingsReader.projectID(environment: environment) + } + + var sourceLabel: String { + self.projectID == nil ? "admin-api" : "admin-api:project" + } + + var allowsLegacyBalanceFallback: Bool { + self.projectID == nil || !self.usesAdminKey } } diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPISettingsReader.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPISettingsReader.swift index 61ab0a2227..a641872d11 100644 --- a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPISettingsReader.swift +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPISettingsReader.swift @@ -3,6 +3,7 @@ import Foundation public enum OpenAIAPISettingsReader { public static let adminAPIKeyEnvironmentKey = "OPENAI_ADMIN_KEY" public static let apiKeyEnvironmentKey = "OPENAI_API_KEY" + public static let projectIDEnvironmentKey = "OPENAI_PROJECT_ID" public static let apiKeyEnvironmentKeys = [ Self.adminAPIKeyEnvironmentKey, Self.apiKeyEnvironmentKey, @@ -15,6 +16,14 @@ public enum OpenAIAPISettingsReader { return nil } + public static func adminAPIKey(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.cleaned(environment[self.adminAPIKeyEnvironmentKey]) + } + + public static func projectID(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.cleaned(environment[self.projectIDEnvironmentKey]) + } + static func cleaned(_ raw: String?) -> String? { guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageFetcher.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageFetcher.swift index 34a77ebf76..135702829a 100644 --- a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageFetcher.swift @@ -40,8 +40,25 @@ public enum OpenAIAPIUsageFetcher { private static let maxDailyBucketLimit = 31 private static let timeoutSeconds: TimeInterval = 20 + private struct EndpointRequestContext { + let apiKey: String + let projectID: String? + let transport: any ProviderHTTPTransport + let retryPolicy: ProviderHTTPRetryPolicy + } + + private struct UsageEndpoint { + let name: String + let baseURL: URL + let queryItems: [URLQueryItem] + let decodeBuckets: (Data) throws -> [Bucket] + } + + private typealias SnapshotMetadata = (now: Date, calendar: Calendar, historyDays: Int, projectID: String?) + public static func fetchUsage( apiKey: String, + projectID: String? = nil, costsURL: URL = Self.organizationCostsURL, completionsURL: URL = Self.organizationCompletionsUsageURL, session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, @@ -53,29 +70,33 @@ public enum OpenAIAPIUsageFetcher { guard !trimmed.isEmpty else { throw OpenAIAPIUsageError.missingCredentials } + let normalizedProjectID = OpenAIAPISettingsReader.cleaned(projectID) let calendar = Self.utcCalendar let clampedHistoryDays = max(1, min(365, historyDays)) let ranges = Self.dailyRanges(now: now, calendar: calendar, historyDays: clampedHistoryDays) - let costs = try await Self.fetchCosts( + let requestContext = EndpointRequestContext( apiKey: trimmed, - baseURL: costsURL, - ranges: ranges, - transport: transport, - retryPolicy: retryPolicy) - let completions = try await Self.fetchCompletions( - apiKey: trimmed, - baseURL: completionsURL, - ranges: ranges, + projectID: normalizedProjectID, transport: transport, retryPolicy: retryPolicy) + let costs = try await Self.fetchBuckets( + endpoint: Self.costsEndpoint(baseURL: costsURL), + context: requestContext, + ranges: ranges) + let completions = try await Self.fetchBuckets( + endpoint: Self.completionsEndpoint(baseURL: completionsURL), + context: requestContext, + ranges: ranges) return Self.makeSnapshot( costs: costs, completions: completions, - now: now, - calendar: calendar, - historyDays: clampedHistoryDays) + metadata: SnapshotMetadata( + now: now, + calendar: calendar, + historyDays: clampedHistoryDays, + projectID: normalizedProjectID)) } static func _parseSnapshotForTesting( @@ -83,68 +104,62 @@ public enum OpenAIAPIUsageFetcher { completions: Data, now: Date, calendar: Calendar = Self.utcCalendar, - historyDays: Int = 30) throws -> OpenAIAPIUsageSnapshot + historyDays: Int = 30, + projectID: String? = nil) throws -> OpenAIAPIUsageSnapshot { - let costs = try Self.decodeCosts(costs) - let completions = try Self.decodeCompletions(completions) - return Self.makeSnapshot( - costs: costs, - completions: completions, - now: now, - calendar: calendar, - historyDays: historyDays) + try self.makeSnapshot( + costs: self.decodeCosts(costs).data, + completions: self.decodeCompletions(completions).data, + metadata: SnapshotMetadata( + now: now, + calendar: calendar, + historyDays: historyDays, + projectID: OpenAIAPISettingsReader.cleaned(projectID))) } - private static func fetchCosts( - apiKey: String, - baseURL: URL, - ranges: [DateRange], - transport: any ProviderHTTPTransport, - retryPolicy: ProviderHTTPRetryPolicy) async throws -> CostsResponse + private static func costsEndpoint(baseURL: URL) -> UsageEndpoint { + UsageEndpoint( + name: "costs", + baseURL: baseURL, + queryItems: [URLQueryItem(name: "group_by", value: "line_item")], + decodeBuckets: { try self.decodeCosts($0).data }) + } + + private static func completionsEndpoint( + baseURL: URL) -> UsageEndpoint { - var buckets: [CostBucket] = [] - for range in ranges { - let url = Self.url( - baseURL: baseURL, - range: range, - queryItems: [ - URLQueryItem(name: "group_by", value: "line_item"), - ]) - let data = try await Self.fetchData( - url: url, - apiKey: apiKey, - endpoint: "costs", - transport: transport, - retryPolicy: retryPolicy) - try buckets.append(contentsOf: Self.decodeCosts(data).data) - } - return CostsResponse(data: buckets) + UsageEndpoint( + name: "completions", + baseURL: baseURL, + queryItems: [URLQueryItem(name: "group_by", value: "model")], + decodeBuckets: { try self.decodeCompletions($0).data }) } - private static func fetchCompletions( - apiKey: String, - baseURL: URL, - ranges: [DateRange], - transport: any ProviderHTTPTransport, - retryPolicy: ProviderHTTPRetryPolicy) async throws -> CompletionsUsageResponse + private static func fetchBuckets( + endpoint: UsageEndpoint, + context: EndpointRequestContext, + ranges: [DateRange]) async throws -> [Bucket] { - var buckets: [CompletionsUsageBucket] = [] + var buckets: [Bucket] = [] for range in ranges { let url = Self.url( - baseURL: baseURL, + baseURL: endpoint.baseURL, range: range, - queryItems: [ - URLQueryItem(name: "group_by", value: "model"), - ]) + queryItems: endpoint.queryItems + Self.projectQueryItems(projectID: context.projectID)) let data = try await Self.fetchData( url: url, - apiKey: apiKey, - endpoint: "completions", - transport: transport, - retryPolicy: retryPolicy) - try buckets.append(contentsOf: Self.decodeCompletions(data).data) + apiKey: context.apiKey, + endpoint: endpoint.name, + transport: context.transport, + retryPolicy: context.retryPolicy) + try buckets.append(contentsOf: endpoint.decodeBuckets(data)) } - return CompletionsUsageResponse(data: buckets) + return buckets + } + + private static func projectQueryItems(projectID: String?) -> [URLQueryItem] { + guard let projectID else { return [] } + return [URLQueryItem(name: "project_ids", value: projectID)] } private static func fetchData( @@ -190,15 +205,13 @@ public enum OpenAIAPIUsageFetcher { } private static func makeSnapshot( - costs: CostsResponse, - completions: CompletionsUsageResponse, - now: Date, - calendar: Calendar, - historyDays: Int) -> OpenAIAPIUsageSnapshot + costs: [OpenAICostBucket], + completions: [OpenAICompletionsUsageBucket], + metadata: SnapshotMetadata) -> OpenAIAPIUsageSnapshot { var accumulators: [Int: DailyAccumulator] = [:] - for bucket in costs.data { + for bucket in costs { var accumulator = accumulators[bucket.startTime] ?? DailyAccumulator( startTime: bucket.startTime, endTime: bucket.endTime) @@ -211,7 +224,7 @@ public enum OpenAIAPIUsageFetcher { accumulators[bucket.startTime] = accumulator } - for bucket in completions.data { + for bucket in completions { var accumulator = accumulators[bucket.startTime] ?? DailyAccumulator( startTime: bucket.startTime, endTime: bucket.endTime) @@ -240,10 +253,14 @@ public enum OpenAIAPIUsageFetcher { } let daily = accumulators.values - .filter { $0.startDate <= now } + .filter { $0.startDate <= metadata.now } .sorted { $0.startTime < $1.startTime } - .map { $0.makeBucket(calendar: calendar) } - return OpenAIAPIUsageSnapshot(daily: daily, updatedAt: now, historyDays: historyDays) + .map { $0.makeBucket(calendar: metadata.calendar) } + return OpenAIAPIUsageSnapshot( + daily: daily, + updatedAt: metadata.now, + historyDays: metadata.historyDays, + projectID: metadata.projectID) } private static func displayName(_ raw: String?, fallback: String) -> String { @@ -377,108 +394,3 @@ private struct ModelAccumulator { totalTokens: self.totalTokens) } } - -private struct CostsResponse: Decodable { - let data: [CostBucket] -} - -private struct CostBucket: Decodable { - let startTime: Int - let endTime: Int - let results: [CostResult] - - private enum CodingKeys: String, CodingKey { - case startTime = "start_time" - case endTime = "end_time" - case results - } -} - -private struct CostResult: Decodable { - struct Amount: Decodable { - let value: Double? - let currency: String? - - private enum CodingKeys: String, CodingKey { - case value - case currency - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.value = try container.decodeFlexibleDoubleIfPresent(forKey: .value) - self.currency = try container.decodeIfPresent(String.self, forKey: .currency) - } - } - - let amount: Amount? - let lineItem: String? - - private enum CodingKeys: String, CodingKey { - case amount - case lineItem = "line_item" - } -} - -extension KeyedDecodingContainer { - fileprivate func decodeFlexibleDoubleIfPresent(forKey key: Key) throws -> Double? { - guard self.contains(key), try !self.decodeNil(forKey: key) else { - return nil - } - - if let value = try? self.decode(Double.self, forKey: key) { - return value - } - - if let rawValue = try? self.decode(String.self, forKey: key) { - let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - return nil - } - if let value = Double(trimmed) { - return value - } - } - - throw DecodingError.dataCorruptedError( - forKey: key, - in: self, - debugDescription: "Expected a number or numeric string for \(key.stringValue)") - } -} - -private struct CompletionsUsageResponse: Decodable { - let data: [CompletionsUsageBucket] -} - -private struct CompletionsUsageBucket: Decodable { - let startTime: Int - let endTime: Int - let results: [CompletionsUsageResult] - - private enum CodingKeys: String, CodingKey { - case startTime = "start_time" - case endTime = "end_time" - case results - } -} - -private struct CompletionsUsageResult: Decodable { - let inputTokens: Int? - let inputCachedTokens: Int? - let inputAudioTokens: Int? - let outputTokens: Int? - let outputAudioTokens: Int? - let numModelRequests: Int? - let model: String? - - private enum CodingKeys: String, CodingKey { - case inputTokens = "input_tokens" - case inputCachedTokens = "input_cached_tokens" - case inputAudioTokens = "input_audio_tokens" - case outputTokens = "output_tokens" - case outputAudioTokens = "output_audio_tokens" - case numModelRequests = "num_model_requests" - case model - } -} diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageResponses.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageResponses.swift new file mode 100644 index 0000000000..02ad04854f --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageResponses.swift @@ -0,0 +1,106 @@ +import Foundation + +struct CostsResponse: Decodable { + let data: [OpenAICostBucket] +} + +struct OpenAICostBucket: Decodable { + let startTime: Int + let endTime: Int + let results: [OpenAICostResult] + + private enum CodingKeys: String, CodingKey { + case startTime = "start_time" + case endTime = "end_time" + case results + } +} + +struct OpenAICostResult: Decodable { + struct Amount: Decodable { + let value: Double? + let currency: String? + + private enum CodingKeys: String, CodingKey { + case value + case currency + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.value = try container.decodeFlexibleDoubleIfPresent(forKey: .value) + self.currency = try container.decodeIfPresent(String.self, forKey: .currency) + } + } + + let amount: Amount? + let lineItem: String? + + private enum CodingKeys: String, CodingKey { + case amount + case lineItem = "line_item" + } +} + +struct CompletionsUsageResponse: Decodable { + let data: [OpenAICompletionsUsageBucket] +} + +struct OpenAICompletionsUsageBucket: Decodable { + let startTime: Int + let endTime: Int + let results: [OpenAICompletionsUsageResult] + + private enum CodingKeys: String, CodingKey { + case startTime = "start_time" + case endTime = "end_time" + case results + } +} + +struct OpenAICompletionsUsageResult: Decodable { + let inputTokens: Int? + let inputCachedTokens: Int? + let inputAudioTokens: Int? + let outputTokens: Int? + let outputAudioTokens: Int? + let numModelRequests: Int? + let model: String? + + private enum CodingKeys: String, CodingKey { + case inputTokens = "input_tokens" + case inputCachedTokens = "input_cached_tokens" + case inputAudioTokens = "input_audio_tokens" + case outputTokens = "output_tokens" + case outputAudioTokens = "output_audio_tokens" + case numModelRequests = "num_model_requests" + case model + } +} + +extension KeyedDecodingContainer { + fileprivate func decodeFlexibleDoubleIfPresent(forKey key: Key) throws -> Double? { + guard self.contains(key), try !self.decodeNil(forKey: key) else { + return nil + } + + if let value = try? self.decode(Double.self, forKey: key) { + return value + } + + if let rawValue = try? self.decode(String.self, forKey: key) { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + if let value = Double(trimmed) { + return value + } + } + + throw DecodingError.dataCorruptedError( + forKey: key, + in: self, + debugDescription: "Expected a number or numeric string for \(key.stringValue)") + } +} diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift index 4bd38d1abd..9f741ea994 100644 --- a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift @@ -116,11 +116,13 @@ public struct OpenAIAPIUsageSnapshot: Codable, Equatable, Sendable { public let daily: [DailyBucket] public let updatedAt: Date public let historyDays: Int + public let projectID: String? - public init(daily: [DailyBucket], updatedAt: Date, historyDays: Int = 30) { + public init(daily: [DailyBucket], updatedAt: Date, historyDays: Int = 30, projectID: String? = nil) { self.daily = daily.sorted { $0.startTime < $1.startTime } self.updatedAt = updatedAt self.historyDays = max(1, min(365, historyDays)) + self.projectID = OpenAIAPISettingsReader.cleaned(projectID) } public var last30Days: Summary { @@ -200,8 +202,18 @@ public struct OpenAIAPIUsageSnapshot: Codable, Equatable, Sendable { identity: ProviderIdentitySnapshot( providerID: .openai, accountEmail: nil, - accountOrganization: nil, - loginMethod: "Admin API")) + accountOrganization: self.identityAccountOrganization, + loginMethod: self.identityLoginMethod)) + } + + private var identityLoginMethod: String { + guard let projectID else { return "Admin API" } + return "Admin API: \(projectID)" + } + + private var identityAccountOrganization: String? { + guard let projectID else { return nil } + return "Project: \(projectID)" } public func toCostUsageTokenSnapshot() -> CostUsageTokenSnapshot { diff --git a/Sources/CodexBarCore/TokenAccountSupport.swift b/Sources/CodexBarCore/TokenAccountSupport.swift index 64b5eaf2d0..37ec660a97 100644 --- a/Sources/CodexBarCore/TokenAccountSupport.swift +++ b/Sources/CodexBarCore/TokenAccountSupport.swift @@ -12,6 +12,7 @@ public struct TokenAccountSupport: Sendable { public let injection: TokenAccountInjection public let requiresManualCookieSource: Bool public let cookieName: String? + public let environmentKeysToScrub: [String] public init( title: String, @@ -19,7 +20,8 @@ public struct TokenAccountSupport: Sendable { placeholder: String, injection: TokenAccountInjection, requiresManualCookieSource: Bool, - cookieName: String?) + cookieName: String?, + environmentKeysToScrub: [String] = []) { self.title = title self.subtitle = subtitle @@ -27,6 +29,7 @@ public struct TokenAccountSupport: Sendable { self.injection = injection self.requiresManualCookieSource = requiresManualCookieSource self.cookieName = cookieName + self.environmentKeysToScrub = environmentKeysToScrub } } @@ -66,6 +69,9 @@ public enum TokenAccountSupportCatalog { switch support.injection { case let .environment(key): environment.removeValue(forKey: key) + for key in support.environmentKeysToScrub { + environment.removeValue(forKey: key) + } case .cookieHeader: guard provider == .claude else { return } environment.removeValue(forKey: ClaudeOAuthCredentialsStore.environmentTokenKey) diff --git a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift index 8a10f92f11..12ec1cee55 100644 --- a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift +++ b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift @@ -8,7 +8,8 @@ extension TokenAccountSupportCatalog { placeholder: "sk-admin-...", injection: .environment(key: OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey), requiresManualCookieSource: false, - cookieName: nil), + cookieName: nil, + environmentKeysToScrub: [OpenAIAPISettingsReader.projectIDEnvironmentKey]), .claude: TokenAccountSupport( title: "Claude credentials", subtitle: "Store Claude sessionKey cookies, OAuth tokens, or Anthropic Admin API keys.", diff --git a/Tests/CodexBarTests/ConfigValidationTests.swift b/Tests/CodexBarTests/ConfigValidationTests.swift index 638a49fa45..da42392cc2 100644 --- a/Tests/CodexBarTests/ConfigValidationTests.swift +++ b/Tests/CodexBarTests/ConfigValidationTests.swift @@ -80,12 +80,26 @@ struct ConfigValidationTests { #expect(!issues.contains(where: { $0.provider == .azureopenai && $0.code == "enterprise_host_unused" })) } + @Test + func `allows OpenAI API project workspace ID`() { + var config = CodexBarConfig.makeDefault() + config.setProviderConfig(ProviderConfig(id: .openai, workspaceID: "proj_abc")) + let issues = CodexBarConfigValidator.validate(config) + + #expect(!issues.contains(where: { $0.provider == .openai && $0.code == "workspace_unused" })) + } + @Test func `warns on unsupported workspace ID`() { var config = CodexBarConfig.makeDefault() config.setProviderConfig(ProviderConfig(id: .gemini, workspaceID: "workspace-123")) let issues = CodexBarConfigValidator.validate(config) #expect(issues.contains(where: { $0.provider == .gemini && $0.code == "workspace_unused" })) + #expect(issues.contains(where: { issue in + issue.provider == .gemini && + issue.code == "workspace_unused" && + issue.message.contains("openai") + })) } @Test diff --git a/Tests/CodexBarTests/MistralUsageParserTests.swift b/Tests/CodexBarTests/MistralUsageParserTests.swift index 3ff1013bb7..b380175365 100644 --- a/Tests/CodexBarTests/MistralUsageParserTests.swift +++ b/Tests/CodexBarTests/MistralUsageParserTests.swift @@ -110,7 +110,8 @@ struct MistralUsageParserTests { #expect(snapshot.startDate != nil) #expect(snapshot.endDate != nil) - let calendar = Calendar.current + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) if let start = snapshot.startDate { #expect(calendar.component(.month, from: start) == 11) #expect(calendar.component(.year, from: start) == 2025) diff --git a/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift b/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift index 2569b228a8..88860dd5f6 100644 --- a/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift +++ b/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift @@ -3,9 +3,21 @@ import Testing @testable import CodexBarCore struct OpenAIAPICreditBalanceTests { - private func makeContext(apiKey: String = "sk-test", historyDays: Int = 30) -> ProviderFetchContext { + private func makeContext( + apiKey: String = "sk-test", + usesAdminKey: Bool = false, + projectID: String? = nil, + selectedTokenAccountID: UUID? = nil, + historyDays: Int = 30) -> ProviderFetchContext + { let browserDetection = BrowserDetection(cacheTTL: 0) - let env = ["OPENAI_API_KEY": apiKey] + let apiKeyEnvironmentKey = usesAdminKey + ? OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey + : OpenAIAPISettingsReader.apiKeyEnvironmentKey + var env = [apiKeyEnvironmentKey: apiKey] + if let projectID { + env[OpenAIAPISettingsReader.projectIDEnvironmentKey] = projectID + } return ProviderFetchContext( runtime: .app, sourceMode: .api, @@ -18,6 +30,7 @@ struct OpenAIAPICreditBalanceTests { fetcher: UsageFetcher(environment: env), claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), browserDetection: browserDetection, + selectedTokenAccountID: selectedTokenAccountID, costUsageHistoryDays: historyDays) } @@ -103,6 +116,62 @@ struct OpenAIAPICreditBalanceTests { #expect(result.usage.identity?.loginMethod == "API balance: $75.00") } + @Test + func `legacy API key without project ID falls back to legacy billing`() async throws { + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "sk-test") + #expect(credential.projectID == nil) + #expect(historyDays == 30) + throw OpenAIAPIUsageError.apiError(endpoint: "costs", statusCode: 403) + }, + balanceFetcher: { apiKey in + #expect(apiKey == "sk-test") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + let result = try await strategy.fetch(self.makeContext()) + + #expect(result.sourceLabel == "billing-api") + #expect(result.usage.identity?.loginMethod == "API balance: $75.00") + #expect(result.usage.identity?.accountOrganization == nil) + } + + @Test + func `selected token account uses scrubbed final environment for legacy fallback`() async throws { + let accountID = UUID() + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "account-token") + #expect(credential.projectID == nil) + #expect(historyDays == 30) + throw OpenAIAPIUsageError.apiError(endpoint: "costs", statusCode: 403) + }, + balanceFetcher: { apiKey in + #expect(apiKey == "account-token") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + let result = try await strategy.fetch(self.makeContext( + apiKey: "account-token", + usesAdminKey: true, + selectedTokenAccountID: accountID)) + + #expect(result.sourceLabel == "billing-api") + #expect(result.usage.identity?.loginMethod == "API balance: $75.00") + #expect(result.usage.identity?.accountOrganization == nil) + } + @Test func `preserves admin usage error when legacy fallback also fails`() async { let usageFailure = OpenAIAPIUsageError.parseFailed(endpoint: "costs", message: "changed") @@ -120,293 +189,12 @@ struct OpenAIAPICreditBalanceTests { } } - @Test - func `parses admin costs and completions usage into daily summaries`() throws { - let now = Date(timeIntervalSince1970: 1_700_179_200) - let costs = """ - { - "object": "page", - "data": [ - { - "object": "bucket", - "start_time": 1700000000, - "end_time": 1700086400, - "results": [ - { - "object": "organization.costs.result", - "amount": { "value": 12.50, "currency": "usd" }, - "line_item": "Text tokens" - }, - { - "object": "organization.costs.result", - "amount": { "value": "2.25", "currency": "usd" }, - "line_item": "Web search tool calls" - } - ] - }, - { - "object": "bucket", - "start_time": 1700086400, - "end_time": 1700172800, - "results": [ - { - "object": "organization.costs.result", - "amount": { "value": 4.00, "currency": "usd" }, - "line_item": "Text tokens" - } - ] - } - ], - "has_more": false, - "next_page": null - } - """ - let completions = """ - { - "object": "page", - "data": [ - { - "object": "bucket", - "start_time": 1700000000, - "end_time": 1700086400, - "results": [ - { - "object": "organization.usage.completions.result", - "input_tokens": 1000, - "input_cached_tokens": 250, - "output_tokens": 500, - "num_model_requests": 7, - "model": "gpt-5.2" - }, - { - "object": "organization.usage.completions.result", - "input_tokens": 300, - "output_tokens": 200, - "num_model_requests": 3, - "model": "gpt-5.2-codex" - } - ] - }, - { - "object": "bucket", - "start_time": 1700086400, - "end_time": 1700172800, - "results": [ - { - "object": "organization.usage.completions.result", - "input_tokens": 200, - "output_tokens": 100, - "num_model_requests": 2, - "model": "gpt-5.2" - } - ] - } - ], - "has_more": false, - "next_page": null - } - """ - - let snapshot = try OpenAIAPIUsageFetcher._parseSnapshotForTesting( - costs: Data(costs.utf8), - completions: Data(completions.utf8), - now: now, - historyDays: 90) - - #expect(snapshot.historyDays == 90) - #expect(snapshot.historyWindowLabel == "90d") - #expect(snapshot.daily.count == 2) - #expect(snapshot.daily[0].costUSD == 14.75) - #expect(snapshot.daily[0].requests == 10) - #expect(snapshot.daily[0].totalTokens == 2000) - #expect(snapshot.daily[0].cachedInputTokens == 250) - #expect(snapshot.daily[0].lineItems.first?.name == "Text tokens") - #expect(snapshot.last30Days.costUSD == 18.75) - #expect(snapshot.last30Days.requests == 12) - #expect(snapshot.last30Days.totalTokens == 2300) - #expect(snapshot.topModels.first?.name == "gpt-5.2") - #expect(snapshot.topModels.first?.totalTokens == 1800) - } - - @Test - func `admin usage fetch pages long history within endpoint bucket limit`() async throws { - let now = Date(timeIntervalSince1970: 1_700_179_200) - let emptyPage = Data(#"{"object":"page","data":[],"has_more":false,"next_page":null}"#.utf8) - let transport = ProviderHTTPTransportStub { request in - let response = try HTTPURLResponse( - url: #require(request.url), - statusCode: 200, - httpVersion: nil, - headerFields: nil)! - return (emptyPage, response) - } - - let snapshot = try await OpenAIAPIUsageFetcher.fetchUsage( - apiKey: "sk-test", - costsURL: #require(URL(string: "https://api.openai.test/v1/organization/costs")), - completionsURL: #require(URL(string: "https://api.openai.test/v1/organization/usage/completions")), - session: transport, - now: now, - historyDays: 90) - - let requests = await transport.requests() - let limits = requests.compactMap { request -> Int? in - guard let url = request.url, - let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - let raw = components.queryItems?.first(where: { $0.name == "limit" })?.value - else { return nil } - return Int(raw) - } - - #expect(snapshot.historyDays == 90) - #expect(requests.count == 6) - #expect(limits == [31, 31, 28, 31, 31, 28]) - #expect(limits.allSatisfy { $0 <= 31 }) - } - - @Test - func `admin usage retries transient completions failure once`() async throws { - let now = Date(timeIntervalSince1970: 1_700_179_200) - let emptyPage = Data(#"{"object":"page","data":[],"has_more":false,"next_page":null}"#.utf8) - let completions = Data(""" - { - "object": "page", - "data": [ - { - "object": "bucket", - "start_time": 1700000000, - "end_time": 1700086400, - "results": [ - { - "object": "organization.usage.completions.result", - "input_tokens": 10, - "output_tokens": 5, - "num_model_requests": 1, - "model": "gpt-5.2" - } - ] - } - ], - "has_more": false, - "next_page": null - } - """.utf8) - let transport = OpenAIAdminUsageRetryScript(costs: emptyPage, completions: completions) - - let snapshot = try await OpenAIAPIUsageFetcher.fetchUsage( - apiKey: "sk-test", - costsURL: #require(URL(string: "https://api.openai.test/v1/organization/costs")), - completionsURL: #require(URL(string: "https://api.openai.test/v1/organization/usage/completions")), - session: transport, - now: now, - historyDays: 1, - retryPolicy: ProviderHTTPRetryPolicy(maxRetries: 1, baseDelaySeconds: 0, maxDelaySeconds: 0)) - - #expect(snapshot.latestDay.totalTokens == 15) - #expect(snapshot.latestDay.requests == 1) - #expect(await transport.completionsRequestCount() == 2) - } - - @Test - func `maps admin usage to openai usage snapshot`() { - let now = Date(timeIntervalSince1970: 1_700_179_200) - let apiUsage = OpenAIAPIUsageSnapshot( - daily: [ - OpenAIAPIUsageSnapshot.DailyBucket( - day: "2023-11-14", - startTime: now, - endTime: now.addingTimeInterval(86400), - costUSD: 8.5, - requests: 42, - inputTokens: 1000, - cachedInputTokens: 400, - outputTokens: 250, - totalTokens: 1250, - lineItems: [], - models: []), - ], - updatedAt: now) - - let usage = apiUsage.toUsageSnapshot() - - #expect(usage.primary == nil) - #expect(usage.providerCost?.used == 8.5) - #expect(usage.providerCost?.limit == 0) - #expect(usage.providerCost?.period == "Last 30 days") - #expect(usage.openAIAPIUsage?.last30Days.requests == 42) - #expect(usage.identity?.loginMethod == "Admin API") - } - - @Test - func `maps admin usage to cost token snapshot`() { - let now = Date(timeIntervalSince1970: 1_700_179_200) - let apiUsage = OpenAIAPIUsageSnapshot( - daily: [ - OpenAIAPIUsageSnapshot.DailyBucket( - day: "2023-11-13", - startTime: now.addingTimeInterval(-86400), - endTime: now, - costUSD: 2.25, - requests: 3, - inputTokens: 300, - cachedInputTokens: 100, - outputTokens: 200, - totalTokens: 500, - lineItems: [], - models: [ - OpenAIAPIUsageSnapshot.ModelBreakdown( - name: "gpt-5.2", - requests: 3, - inputTokens: 300, - cachedInputTokens: 100, - outputTokens: 200, - totalTokens: 500), - ]), - OpenAIAPIUsageSnapshot.DailyBucket( - day: "2023-11-14", - startTime: now, - endTime: now.addingTimeInterval(86400), - costUSD: 8.5, - requests: 42, - inputTokens: 1000, - cachedInputTokens: 400, - outputTokens: 250, - totalTokens: 1250, - lineItems: [], - models: [ - OpenAIAPIUsageSnapshot.ModelBreakdown( - name: "gpt-5.2-codex", - requests: 42, - inputTokens: 1000, - cachedInputTokens: 400, - outputTokens: 250, - totalTokens: 1250), - ]), - ], - updatedAt: now, - historyDays: 7) - - let snapshot = apiUsage.toCostUsageTokenSnapshot() - - #expect(snapshot.historyDays == 7) - #expect(snapshot.sessionCostUSD == 8.5) - #expect(snapshot.sessionTokens == 1250) - #expect(snapshot.sessionRequests == 42) - #expect(snapshot.last30DaysCostUSD == 10.75) - #expect(snapshot.last30DaysTokens == 1750) - #expect(snapshot.last30DaysRequests == 45) - #expect(snapshot.daily.count == 2) - #expect(snapshot.daily[1].cacheReadTokens == 400) - #expect(snapshot.daily[1].requestCount == 42) - #expect(snapshot.daily[1].modelBreakdowns?.first?.requestCount == 42) - #expect(snapshot.daily[1].modelBreakdowns?.first?.modelName == "gpt-5.2-codex") - } - @Test func `falls back to credit balance when admin usage endpoint is unavailable`() async throws { let strategy = OpenAIAPIBalanceFetchStrategy( - usageFetcher: { apiKey, historyDays in - #expect(apiKey == "sk-test") + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "sk-test") + #expect(credential.projectID == nil) #expect(historyDays == 90) throw OpenAIAPIUsageError.apiError(endpoint: "costs", statusCode: 500) }, @@ -427,43 +215,3 @@ struct OpenAIAPICreditBalanceTests { #expect(result.usage.providerCost?.limit == 100) } } - -private actor OpenAIAdminUsageRetryScript: ProviderHTTPTransport { - private let costs: Data - private let completions: Data - private var completionsRequests = 0 - - init(costs: Data, completions: Data) { - self.costs = costs - self.completions = completions - } - - func completionsRequestCount() -> Int { - self.completionsRequests - } - - func data(for request: URLRequest) throws -> (Data, URLResponse) { - let url = request.url ?? URL(string: "https://api.openai.test")! - if url.path.contains("/usage/completions") { - self.completionsRequests += 1 - if self.completionsRequests == 1 { - return (Data(), HTTPURLResponse( - url: url, - statusCode: 503, - httpVersion: "HTTP/1.1", - headerFields: nil)!) - } - return (self.completions, HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: "HTTP/1.1", - headerFields: nil)!) - } - - return (self.costs, HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: "HTTP/1.1", - headerFields: nil)!) - } -} diff --git a/Tests/CodexBarTests/OpenAIAPIProjectScopeTests.swift b/Tests/CodexBarTests/OpenAIAPIProjectScopeTests.swift new file mode 100644 index 0000000000..a8a0f3f515 --- /dev/null +++ b/Tests/CodexBarTests/OpenAIAPIProjectScopeTests.swift @@ -0,0 +1,334 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCLI +@testable import CodexBarCore + +@Suite(.serialized) +struct OpenAIAPIProjectScopeTests { + @Test + @MainActor + func `token account strips configured project in app environment builder`() { + let settings = Self.makeSettingsStore(suite: "OpenAIAPIProjectScopeTests-app") + settings.openAIAPIKey = "config-token" + settings.openAIAPIProjectID = "proj_config" + settings.addTokenAccount(provider: .openai, label: "Configured account", token: "first-account-token") + settings.addTokenAccount(provider: .openai, label: "Selected account", token: "selected-account-token") + let selectedAccount = settings.tokenAccounts(for: .openai)[1] + + let env = ProviderRegistry.makeEnvironment( + base: [OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_env"], + provider: .openai, + settings: settings, + tokenOverride: TokenAccountOverride(provider: .openai, account: selectedAccount)) + + #expect(env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] == "selected-account-token") + #expect(env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] != "config-token") + #expect(env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] != "first-account-token") + #expect(env[OpenAIAPISettingsReader.projectIDEnvironmentKey] == nil) + } + + @Test + func `token account strips configured project in CLI environment builder`() throws { + let account = ProviderTokenAccount( + id: UUID(), + label: "Project account", + token: "account-token", + addedAt: Date().timeIntervalSince1970, + lastUsed: nil) + let accounts = ProviderTokenAccountData(version: 1, accounts: [account], activeIndex: 0) + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .openai, + apiKey: "config-token", + workspaceID: "proj_config", + tokenAccounts: accounts), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + + let env = tokenContext.environment( + base: [OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_env"], + provider: .openai, + account: account) + + #expect(env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] == "account-token") + #expect(env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] != "config-token") + #expect(env[OpenAIAPISettingsReader.projectIDEnvironmentKey] == nil) + } + + @Test + @MainActor + func `configured app project scopes admin usage strategy`() async throws { + let settings = Self.makeSettingsStore(suite: "OpenAIAPIProjectScopeTests-configured-project") + settings.openAIAPIKey = "config-token" + settings.openAIAPIProjectID = "proj_config" + let env = ProviderRegistry.makeEnvironment( + base: [:], + provider: .openai, + settings: settings, + tokenOverride: nil) + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "config-token") + #expect(credential.projectID == "proj_config") + #expect(historyDays == 30) + return OpenAIAPIUsageSnapshot( + daily: [], + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + projectID: credential.projectID) + }, + balanceFetcher: { _ in + Issue.record("Configured project usage should not fetch legacy organization balance.") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + let result = try await strategy.fetch(Self.makeContext(env: env)) + + #expect(result.sourceLabel == "admin-api:project") + #expect(result.usage.identity?.loginMethod == "Admin API: proj_config") + #expect(result.usage.identity?.accountOrganization == "Project: proj_config") + } + + @Test + func `legacy API key environment can scope admin usage by project`() async throws { + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "sk-admin-legacy") + #expect(credential.projectID == "proj_legacy") + #expect(credential.usesAdminKey == false) + #expect(historyDays == 30) + return OpenAIAPIUsageSnapshot( + daily: [], + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + projectID: credential.projectID) + }, + balanceFetcher: { _ in + Issue.record("Legacy OPENAI_API_KEY project usage should not fetch unscoped balance.") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + let result = try await strategy.fetch(Self.makeContext( + env: [ + OpenAIAPISettingsReader.apiKeyEnvironmentKey: "sk-admin-legacy", + OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_legacy", + ])) + + #expect(result.sourceLabel == "admin-api:project") + #expect(result.usage.identity?.loginMethod == "Admin API: proj_legacy") + #expect(result.usage.identity?.accountOrganization == "Project: proj_legacy") + } + + @Test + func `ambient project with legacy API key preserves billing fallback`() async throws { + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "sk-ambient") + #expect(credential.projectID == "proj_ambient") + #expect(credential.usesAdminKey == false) + #expect(historyDays == 30) + throw OpenAIAPIUsageError.apiError(endpoint: "costs", statusCode: 403) + }, + balanceFetcher: { apiKey in + #expect(apiKey == "sk-ambient") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + let result = try await strategy.fetch(Self.makeContext( + env: [ + OpenAIAPISettingsReader.apiKeyEnvironmentKey: "sk-ambient", + OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_ambient", + ])) + + #expect(result.sourceLabel == "billing-api") + #expect(result.usage.identity?.loginMethod == "API balance: $75.00") + #expect(result.usage.identity?.accountOrganization == nil) + } + + @Test + func `project filtered admin usage does not fall back on service failure`() async { + let usageFailure = OpenAIAPIUsageError.apiError(endpoint: "costs", statusCode: 500) + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "sk-test") + #expect(credential.projectID == "proj_abc") + #expect(credential.usesAdminKey == true) + #expect(historyDays == 30) + throw usageFailure + }, + balanceFetcher: { _ in + Issue.record("Project-filtered usage must not fall back to organization balance.") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + do { + _ = try await strategy.fetch(Self.makeContext( + env: [ + OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey: "sk-test", + OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_abc", + ])) + Issue.record("Expected project-filtered admin usage failure.") + } catch let error as OpenAIAPIUsageError { + #expect(error == usageFailure) + } catch { + Issue.record("Expected OpenAIAPIUsageError, got \(error)") + } + } + + @Test + func `project filtered admin usage does not fall back on credential rejection`() async { + let usageFailure = OpenAIAPIUsageError.apiError(endpoint: "costs", statusCode: 403) + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "sk-test") + #expect(credential.projectID == "proj_abc") + #expect(historyDays == 30) + throw usageFailure + }, + balanceFetcher: { _ in + Issue.record("Project-filtered usage must fail closed instead of showing unscoped balance.") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + do { + _ = try await strategy.fetch(Self.makeContext( + env: [ + OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey: "sk-test", + OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_abc", + ])) + Issue.record("Expected project-filtered admin credential failure.") + } catch let error as OpenAIAPIUsageError { + #expect(error == usageFailure) + } catch { + Issue.record("Expected OpenAIAPIUsageError, got \(error)") + } + } + + @Test + func `project filtered admin usage reports project source label`() async throws { + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, _ in + #expect(credential.apiKey == "sk-test") + #expect(credential.projectID == "proj_abc") + return OpenAIAPIUsageSnapshot( + daily: [], + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + projectID: credential.projectID) + }, + balanceFetcher: { _ in + Issue.record("Project-filtered usage should not fetch legacy balance after admin usage succeeds.") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + let result = try await strategy.fetch(Self.makeContext( + env: [ + OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey: "sk-test", + OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_abc", + ])) + + #expect(result.sourceLabel == "admin-api:project") + #expect(result.usage.identity?.loginMethod == "Admin API: proj_abc") + #expect(result.usage.identity?.accountOrganization == "Project: proj_abc") + } + + @Test + func `project scope follows final environment even when selected account flag is present`() async throws { + let accountID = UUID() + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "sk-test") + #expect(credential.projectID == "proj_env") + #expect(historyDays == 30) + return OpenAIAPIUsageSnapshot( + daily: [], + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + projectID: credential.projectID) + }, + balanceFetcher: { _ in + Issue.record("Final project-scoped environments should not fetch legacy balance.") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + let result = try await strategy.fetch(Self.makeContext( + env: [ + OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey: "sk-test", + OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_env", + ], + selectedTokenAccountID: accountID)) + + #expect(result.sourceLabel == "admin-api:project") + #expect(result.usage.identity?.loginMethod == "Admin API: proj_env") + #expect(result.usage.identity?.accountOrganization == "Project: proj_env") + } + + private static func makeContext( + env: [String: String], + selectedTokenAccountID: UUID? = nil, + historyDays: Int = 30) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .app, + sourceMode: .api, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: nil, + fetcher: UsageFetcher(environment: env), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection, + selectedTokenAccountID: selectedTokenAccountID, + costUsageHistoryDays: historyDays) + } + + @MainActor + private static func makeSettingsStore(suite: String) -> SettingsStore { + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + tokenAccountStore: InMemoryTokenAccountStore()) + } +} diff --git a/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift b/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift new file mode 100644 index 0000000000..df6f513b6f --- /dev/null +++ b/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift @@ -0,0 +1,376 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct OpenAIAPIUsageFetcherTests { + @Test + func `parses admin costs and completions usage into daily summaries`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let costs = """ + { + "object": "page", + "data": [ + { + "object": "bucket", + "start_time": 1700000000, + "end_time": 1700086400, + "results": [ + { + "object": "organization.costs.result", + "amount": { "value": 12.50, "currency": "usd" }, + "line_item": "Text tokens" + }, + { + "object": "organization.costs.result", + "amount": { "value": "2.25", "currency": "usd" }, + "line_item": "Web search tool calls" + } + ] + }, + { + "object": "bucket", + "start_time": 1700086400, + "end_time": 1700172800, + "results": [ + { + "object": "organization.costs.result", + "amount": { "value": 4.00, "currency": "usd" }, + "line_item": "Text tokens" + } + ] + } + ], + "has_more": false, + "next_page": null + } + """ + let completions = """ + { + "object": "page", + "data": [ + { + "object": "bucket", + "start_time": 1700000000, + "end_time": 1700086400, + "results": [ + { + "object": "organization.usage.completions.result", + "input_tokens": 1000, + "input_cached_tokens": 250, + "output_tokens": 500, + "num_model_requests": 7, + "model": "gpt-5.2" + }, + { + "object": "organization.usage.completions.result", + "input_tokens": 300, + "output_tokens": 200, + "num_model_requests": 3, + "model": "gpt-5.2-codex" + } + ] + }, + { + "object": "bucket", + "start_time": 1700086400, + "end_time": 1700172800, + "results": [ + { + "object": "organization.usage.completions.result", + "input_tokens": 200, + "output_tokens": 100, + "num_model_requests": 2, + "model": "gpt-5.2" + } + ] + } + ], + "has_more": false, + "next_page": null + } + """ + + let snapshot = try OpenAIAPIUsageFetcher._parseSnapshotForTesting( + costs: Data(costs.utf8), + completions: Data(completions.utf8), + now: now, + historyDays: 90) + + #expect(snapshot.historyDays == 90) + #expect(snapshot.historyWindowLabel == "90d") + #expect(snapshot.daily.count == 2) + #expect(snapshot.daily[0].costUSD == 14.75) + #expect(snapshot.daily[0].requests == 10) + #expect(snapshot.daily[0].totalTokens == 2000) + #expect(snapshot.daily[0].cachedInputTokens == 250) + #expect(snapshot.daily[0].lineItems.first?.name == "Text tokens") + #expect(snapshot.last30Days.costUSD == 18.75) + #expect(snapshot.last30Days.requests == 12) + #expect(snapshot.last30Days.totalTokens == 2300) + #expect(snapshot.topModels.first?.name == "gpt-5.2") + #expect(snapshot.topModels.first?.totalTokens == 1800) + } + + @Test + func `admin usage fetch pages long history within endpoint bucket limit`() async throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let emptyPage = Data(#"{"object":"page","data":[],"has_more":false,"next_page":null}"#.utf8) + let transport = ProviderHTTPTransportStub { request in + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (emptyPage, response) + } + + let snapshot = try await OpenAIAPIUsageFetcher.fetchUsage( + apiKey: "sk-test", + costsURL: #require(URL(string: "https://api.openai.test/v1/organization/costs")), + completionsURL: #require(URL(string: "https://api.openai.test/v1/organization/usage/completions")), + session: transport, + now: now, + historyDays: 90) + + let requests = await transport.requests() + let limits = requests.compactMap { request -> Int? in + guard let url = request.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let raw = components.queryItems?.first(where: { $0.name == "limit" })?.value + else { return nil } + return Int(raw) + } + + #expect(snapshot.historyDays == 90) + #expect(requests.count == 6) + #expect(limits == [31, 31, 28, 31, 31, 28]) + #expect(limits.allSatisfy { $0 <= 31 }) + } + + @Test + func `admin usage filters costs and completions by project`() async throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let emptyPage = Data(#"{"object":"page","data":[],"has_more":false,"next_page":null}"#.utf8) + let transport = ProviderHTTPTransportStub { request in + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (emptyPage, response) + } + + let snapshot = try await OpenAIAPIUsageFetcher.fetchUsage( + apiKey: "sk-test", + projectID: " proj_abc ", + costsURL: #require(URL(string: "https://api.openai.test/v1/organization/costs")), + completionsURL: #require(URL(string: "https://api.openai.test/v1/organization/usage/completions")), + session: transport, + now: now, + historyDays: 1) + + let requests = await transport.requests() + let projectIDs = requests.compactMap { request -> String? in + guard let url = request.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { return nil } + return components.queryItems?.first(where: { $0.name == "project_ids" })?.value + } + let groupBys = requests.compactMap { request -> String? in + guard let url = request.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { return nil } + return components.queryItems?.first(where: { $0.name == "group_by" })?.value + } + + #expect(snapshot.projectID == "proj_abc") + #expect(snapshot.toUsageSnapshot().identity?.accountOrganization == "Project: proj_abc") + #expect(requests.count == 2) + #expect(projectIDs == ["proj_abc", "proj_abc"]) + #expect(groupBys == ["line_item", "model"]) + } + + @Test + func `admin usage retries transient completions failure once`() async throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let emptyPage = Data(#"{"object":"page","data":[],"has_more":false,"next_page":null}"#.utf8) + let completions = Data(""" + { + "object": "page", + "data": [ + { + "object": "bucket", + "start_time": 1700000000, + "end_time": 1700086400, + "results": [ + { + "object": "organization.usage.completions.result", + "input_tokens": 10, + "output_tokens": 5, + "num_model_requests": 1, + "model": "gpt-5.2" + } + ] + } + ], + "has_more": false, + "next_page": null + } + """.utf8) + let transport = OpenAIAdminUsageRetryScript(costs: emptyPage, completions: completions) + + let snapshot = try await OpenAIAPIUsageFetcher.fetchUsage( + apiKey: "sk-test", + costsURL: #require(URL(string: "https://api.openai.test/v1/organization/costs")), + completionsURL: #require(URL(string: "https://api.openai.test/v1/organization/usage/completions")), + session: transport, + now: now, + historyDays: 1, + retryPolicy: ProviderHTTPRetryPolicy(maxRetries: 1, baseDelaySeconds: 0, maxDelaySeconds: 0)) + + #expect(snapshot.latestDay.totalTokens == 15) + #expect(snapshot.latestDay.requests == 1) + #expect(await transport.completionsRequestCount() == 2) + } + + @Test + func `maps admin usage to openai usage snapshot`() { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let apiUsage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: now, + endTime: now.addingTimeInterval(86400), + costUSD: 8.5, + requests: 42, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + totalTokens: 1250, + lineItems: [], + models: []), + ], + updatedAt: now) + + let usage = apiUsage.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.providerCost?.used == 8.5) + #expect(usage.providerCost?.limit == 0) + #expect(usage.providerCost?.period == "Last 30 days") + #expect(usage.openAIAPIUsage?.last30Days.requests == 42) + #expect(usage.identity?.loginMethod == "Admin API") + } + + @Test + func `maps project scoped admin usage to cost token snapshot`() { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let apiUsage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-13", + startTime: now.addingTimeInterval(-86400), + endTime: now, + costUSD: 2.25, + requests: 3, + inputTokens: 300, + cachedInputTokens: 100, + outputTokens: 200, + totalTokens: 500, + lineItems: [], + models: [ + OpenAIAPIUsageSnapshot.ModelBreakdown( + name: "gpt-5.2", + requests: 3, + inputTokens: 300, + cachedInputTokens: 100, + outputTokens: 200, + totalTokens: 500), + ]), + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: now, + endTime: now.addingTimeInterval(86400), + costUSD: 8.5, + requests: 42, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + totalTokens: 1250, + lineItems: [], + models: [ + OpenAIAPIUsageSnapshot.ModelBreakdown( + name: "gpt-5.2-codex", + requests: 42, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + totalTokens: 1250), + ]), + ], + updatedAt: now, + historyDays: 7, + projectID: " proj_abc ") + + let usage = apiUsage.toUsageSnapshot() + let snapshot = apiUsage.toCostUsageTokenSnapshot() + + #expect(apiUsage.projectID == "proj_abc") + #expect(usage.identity?.loginMethod == "Admin API: proj_abc") + #expect(usage.identity?.accountOrganization == "Project: proj_abc") + #expect(snapshot.historyDays == 7) + #expect(snapshot.currencyCode == "USD") + #expect(snapshot.sessionCostUSD == 8.5) + #expect(snapshot.sessionTokens == 1250) + #expect(snapshot.sessionRequests == 42) + #expect(snapshot.last30DaysCostUSD == 10.75) + #expect(snapshot.last30DaysTokens == 1750) + #expect(snapshot.last30DaysRequests == 45) + #expect(snapshot.daily.count == 2) + #expect(snapshot.daily[1].cacheReadTokens == 400) + #expect(snapshot.daily[1].requestCount == 42) + #expect(snapshot.daily[1].modelBreakdowns?.first?.requestCount == 42) + #expect(snapshot.daily[1].modelBreakdowns?.first?.modelName == "gpt-5.2-codex") + } +} + +private actor OpenAIAdminUsageRetryScript: ProviderHTTPTransport { + private let costs: Data + private let completions: Data + private var completionsRequests = 0 + + init(costs: Data, completions: Data) { + self.costs = costs + self.completions = completions + } + + func completionsRequestCount() -> Int { + self.completionsRequests + } + + func data(for request: URLRequest) throws -> (Data, URLResponse) { + let url = request.url ?? URL(string: "https://api.openai.test")! + if url.path.contains("/usage/completions") { + self.completionsRequests += 1 + if self.completionsRequests == 1 { + return (Data(), HTTPURLResponse( + url: url, + statusCode: 503, + httpVersion: "HTTP/1.1", + headerFields: nil)!) + } + return (self.completions, HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil)!) + } + + return (self.costs, HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil)!) + } +} diff --git a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift index 7032c9764f..de81c9f204 100644 --- a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift +++ b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift @@ -121,6 +121,21 @@ struct ProviderConfigEnvironmentTests { #expect(ProviderTokenResolver.openAIAPIToken(environment: env) == "config-openai-token") } + @Test + func `openai config override applies project ID without replacing environment key`() { + let config = ProviderConfig(id: .openai, workspaceID: "proj_config") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [ + OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey: "env-admin-token", + ], + provider: .openai, + config: config) + + #expect(env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] == "env-admin-token") + #expect(env[OpenAIAPISettingsReader.projectIDEnvironmentKey] == "proj_config") + #expect(OpenAIAPISettingsReader.projectID(environment: env) == "proj_config") + } + @Test func `applies Azure OpenAI config overrides`() { let config = ProviderConfig( diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index 122f8e11b4..43ae2fe0e4 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -8,60 +8,13 @@ import Testing struct ProviderSettingsDescriptorTests { @Test func `toggle I ds are unique across providers`() throws { - let suite = "ProviderSettingsDescriptorTests-unique" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) - let store = UsageStore( - fetcher: UsageFetcher(environment: [:]), - browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) - - var statusByID: [String: String] = [:] - var lastRunAtByID: [String: Date] = [:] + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-unique") var seenToggleIDs: Set = [] var seenActionIDs: Set = [] var seenPickerIDs: Set = [] for provider in UsageProvider.allCases { - let context = ProviderSettingsContext( - provider: provider, - settings: settings, - store: store, - boolBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - stringBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - statusText: { id in statusByID[id] }, - setStatusText: { id, text in - if let text { - statusByID[id] = text - } else { - statusByID.removeValue(forKey: id) - } - }, - lastAppActiveRunAt: { id in lastRunAtByID[id] }, - setLastAppActiveRunAt: { id, date in - if let date { - lastRunAtByID[id] = date - } else { - lastRunAtByID.removeValue(forKey: id) - } - }, - requestConfirmation: { _ in }, - runLoginFlow: {}) - + let context = fixture.settingsContext(provider: provider) let impl = try #require(ProviderCatalog.implementation(for: provider)) let toggles = impl.settingsToggles(context: context) for toggle in toggles { @@ -83,41 +36,24 @@ struct ProviderSettingsDescriptorTests { } @Test - func `codex exposes usage and cookie pickers`() throws { - let suite = "ProviderSettingsDescriptorTests-codex" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) - let store = UsageStore( - fetcher: UsageFetcher(environment: [:]), - browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) + func `openai exposes project id setting`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-openai-project") + let context = fixture.settingsContext(provider: .openai) + + let fields = OpenAIAPIProviderImplementation().settingsFields(context: context) + let project = try #require(fields.first(where: { $0.id == "openai-project-id" })) + project.binding.wrappedValue = "proj_abc" + + #expect(project.title == "Project ID") + #expect(project.subtitle.contains(OpenAIAPISettingsReader.projectIDEnvironmentKey)) + #expect(fixture.settings.openAIAPIProjectID == "proj_abc") + #expect(fixture.settings.providerConfig(for: .openai)?.sanitizedWorkspaceID == "proj_abc") + } - let context = ProviderSettingsContext( - provider: .codex, - settings: settings, - store: store, - boolBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - stringBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - statusText: { _ in nil }, - setStatusText: { _, _ in }, - lastAppActiveRunAt: { _ in nil }, - setLastAppActiveRunAt: { _, _ in }, - requestConfirmation: { _ in }, - runLoginFlow: {}) + @Test + func `codex exposes usage and cookie pickers`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-codex") + let context = fixture.settingsContext(provider: .codex) let pickers = CodexProviderImplementation().settingsPickers(context: context) let toggles = CodexProviderImplementation().settingsToggles(context: context) @@ -128,39 +64,8 @@ struct ProviderSettingsDescriptorTests { @Test func `codex exposes open AI web extras toggle as default off opt in`() throws { - let suite = "ProviderSettingsDescriptorTests-codex-openai-toggle" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) - let store = UsageStore( - fetcher: UsageFetcher(environment: [:]), - browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) - - let context = ProviderSettingsContext( - provider: .codex, - settings: settings, - store: store, - boolBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - stringBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - statusText: { _ in nil }, - setStatusText: { _, _ in }, - lastAppActiveRunAt: { _ in nil }, - setLastAppActiveRunAt: { _, _ in }, - requestConfirmation: { _ in }) + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-codex-openai-toggle") + let context = fixture.settingsContext(provider: .codex) let toggles = CodexProviderImplementation().settingsToggles(context: context) let extrasToggle = try #require(toggles.first(where: { $0.id == "codex-openai-web-extras" })) @@ -172,47 +77,16 @@ struct ProviderSettingsDescriptorTests { #expect(batterySaverToggle.binding.wrappedValue == false) #expect(batterySaverToggle.isVisible?() == false) - settings.openAIWebAccessEnabled = true + fixture.settings.openAIWebAccessEnabled = true #expect(batterySaverToggle.isVisible?() == true) } @Test func `claude exposes usage and cookie pickers`() throws { - let suite = "ProviderSettingsDescriptorTests-claude" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) - settings.debugDisableKeychainAccess = false - let store = UsageStore( - fetcher: UsageFetcher(environment: [:]), - browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-claude") + fixture.settings.debugDisableKeychainAccess = false + let context = fixture.settingsContext(provider: .claude) - let context = ProviderSettingsContext( - provider: .claude, - settings: settings, - store: store, - boolBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - stringBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - statusText: { _ in nil }, - setStatusText: { _, _ in }, - lastAppActiveRunAt: { _ in nil }, - setLastAppActiveRunAt: { _, _ in }, - requestConfirmation: { _ in }, - runLoginFlow: {}) let pickers = ClaudeProviderImplementation().settingsPickers(context: context) #expect(pickers.contains(where: { $0.id == "claude-usage-source" })) #expect(pickers.contains(where: { $0.id == "claude-cookie-source" })) @@ -228,43 +102,11 @@ struct ProviderSettingsDescriptorTests { @Test func `claude prompt policy picker hidden when experimental reader selected`() throws { - let suite = "ProviderSettingsDescriptorTests-claude-prompt-hidden-experimental" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) - settings.debugDisableKeychainAccess = false - settings.claudeOAuthKeychainReadStrategy = .securityCLIExperimental - - let store = UsageStore( - fetcher: UsageFetcher(environment: [:]), - browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) - - let context = ProviderSettingsContext( - provider: .claude, - settings: settings, - store: store, - boolBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - stringBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - statusText: { _ in nil }, - setStatusText: { _, _ in }, - lastAppActiveRunAt: { _ in nil }, - setLastAppActiveRunAt: { _, _ in }, - requestConfirmation: { _ in }, - runLoginFlow: {}) + let fixture = try self.makeSettingsFixture( + suite: "ProviderSettingsDescriptorTests-claude-prompt-hidden-experimental") + fixture.settings.debugDisableKeychainAccess = false + fixture.settings.claudeOAuthKeychainReadStrategy = .securityCLIExperimental + let context = fixture.settingsContext(provider: .claude) let pickers = ClaudeProviderImplementation().settingsPickers(context: context) let keychainPicker = try #require(pickers.first(where: { $0.id == "claude-keychain-prompt-policy" })) @@ -273,41 +115,9 @@ struct ProviderSettingsDescriptorTests { @Test func `claude keychain prompt policy picker disabled when global keychain disabled`() throws { - let suite = "ProviderSettingsDescriptorTests-claude-keychain-disabled" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) - settings.debugDisableKeychainAccess = true - let store = UsageStore( - fetcher: UsageFetcher(environment: [:]), - browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) - - let context = ProviderSettingsContext( - provider: .claude, - settings: settings, - store: store, - boolBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - stringBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - statusText: { _ in nil }, - setStatusText: { _, _ in }, - lastAppActiveRunAt: { _ in nil }, - setLastAppActiveRunAt: { _, _ in }, - requestConfirmation: { _ in }, - runLoginFlow: {}) + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-claude-keychain-disabled") + fixture.settings.debugDisableKeychainAccess = true + let context = fixture.settingsContext(provider: .claude) let pickers = ClaudeProviderImplementation().settingsPickers(context: context) let keychainPicker = try #require(pickers.first(where: { $0.id == "claude-keychain-prompt-policy" })) @@ -318,15 +128,8 @@ struct ProviderSettingsDescriptorTests { @Test func `claude web extras auto disables when leaving CLI`() throws { - let suite = "ProviderSettingsDescriptorTests-claude-invariant" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-claude-invariant") + let settings = fixture.settings settings.debugMenuEnabled = true settings.claudeUsageDataSource = .cli settings.claudeWebExtrasEnabled = true @@ -337,40 +140,8 @@ struct ProviderSettingsDescriptorTests { @Test func `kilo exposes usage source picker and api field only`() throws { - let suite = "ProviderSettingsDescriptorTests-kilo" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) - let store = UsageStore( - fetcher: UsageFetcher(environment: [:]), - browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) - - let context = ProviderSettingsContext( - provider: .kilo, - settings: settings, - store: store, - boolBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - stringBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - statusText: { _ in nil }, - setStatusText: { _, _ in }, - lastAppActiveRunAt: { _ in nil }, - setLastAppActiveRunAt: { _, _ in }, - requestConfirmation: { _ in }, - runLoginFlow: {}) + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-kilo") + let context = fixture.settingsContext(provider: .kilo) let implementation = KiloProviderImplementation() let toggles = implementation.settingsToggles(context: context) @@ -384,40 +155,8 @@ struct ProviderSettingsDescriptorTests { @Test func `deepgram exposes api key and project id fields`() throws { - let suite = "ProviderSettingsDescriptorTests-deepgram" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) - let store = UsageStore( - fetcher: UsageFetcher(environment: [:]), - browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) - - let context = ProviderSettingsContext( - provider: .deepgram, - settings: settings, - store: store, - boolBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - stringBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - statusText: { _ in nil }, - setStatusText: { _, _ in }, - lastAppActiveRunAt: { _ in nil }, - setLastAppActiveRunAt: { _, _ in }, - requestConfirmation: { _ in }, - runLoginFlow: {}) + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepgram") + let context = fixture.settingsContext(provider: .deepgram) let implementation = DeepgramProviderImplementation() let fields = implementation.settingsFields(context: context) @@ -432,75 +171,102 @@ struct ProviderSettingsDescriptorTests { @Test func `alibaba presentation follows store source label`() throws { - let suite = "ProviderSettingsDescriptorTests-alibaba-presentation" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) - let store = UsageStore( - fetcher: UsageFetcher(environment: [:]), - browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-alibaba-presentation") let metadata = try #require(ProviderDescriptorRegistry.metadata[.alibaba]) - let context = ProviderPresentationContext( - provider: .alibaba, - settings: settings, - store: store, - metadata: metadata) + let context = fixture.presentationContext(provider: .alibaba, metadata: metadata) let detailLine = AlibabaCodingPlanProviderImplementation() .presentation(context: context) .detailLine(context) - #expect(detailLine == store.sourceLabel(for: .alibaba)) + #expect(detailLine == fixture.store.sourceLabel(for: .alibaba)) } @Test func `alibaba token plan settings expose cookie controls`() throws { - let suite = "ProviderSettingsDescriptorTests-alibaba-token-plan-settings" + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-alibaba-token-plan-settings") + fixture.settings.alibabaTokenPlanCookieSource = .manual + let context = fixture.settingsContext(provider: .alibabatokenplan) + let implementation = AlibabaTokenPlanProviderImplementation() + let pickers = implementation.settingsPickers(context: context) + let fields = implementation.settingsFields(context: context) + + #expect(pickers.contains(where: { $0.id == "alibaba-token-plan-cookie-source" })) + #expect(fields.contains(where: { $0.id == "alibaba-token-plan-cookie" })) + #expect(fields.first?.actions.contains(where: { $0.id == "alibaba-token-plan-open-dashboard" }) == true) + } + + private func makeSettingsFixture(suite: String) throws -> ProviderSettingsFixture { let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) let settings = SettingsStore( userDefaults: defaults, - configStore: configStore, + configStore: testConfigStore(suiteName: suite), zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) let store = UsageStore( fetcher: UsageFetcher(environment: [:]), browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) - let context = ProviderSettingsContext( - provider: .alibabatokenplan, - settings: settings, - store: store, - boolBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - stringBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - statusText: { _ in nil }, - setStatusText: { _, _ in }, - lastAppActiveRunAt: { _ in nil }, - setLastAppActiveRunAt: { _, _ in }, - requestConfirmation: { _ in }) - - settings.alibabaTokenPlanCookieSource = .manual - let implementation = AlibabaTokenPlanProviderImplementation() - let pickers = implementation.settingsPickers(context: context) - let fields = implementation.settingsFields(context: context) + return ProviderSettingsFixture(settings: settings, store: store) + } - #expect(pickers.contains(where: { $0.id == "alibaba-token-plan-cookie-source" })) - #expect(fields.contains(where: { $0.id == "alibaba-token-plan-cookie" })) - #expect(fields.first?.actions.contains(where: { $0.id == "alibaba-token-plan-open-dashboard" }) == true) + private struct ProviderSettingsFixture { + let settings: SettingsStore + let store: UsageStore + private let state = ProviderSettingsContextState() + + @MainActor + func settingsContext(provider: UsageProvider) -> ProviderSettingsContext { + let settings = self.settings + let store = self.store + let state = self.state + return ProviderSettingsContext( + provider: provider, + settings: settings, + store: store, + boolBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + stringBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + statusText: { id in state.statusByID[id] }, + setStatusText: { id, text in + if let text { + state.statusByID[id] = text + } else { + state.statusByID.removeValue(forKey: id) + } + }, + lastAppActiveRunAt: { id in state.lastRunAtByID[id] }, + setLastAppActiveRunAt: { id, date in + if let date { + state.lastRunAtByID[id] = date + } else { + state.lastRunAtByID.removeValue(forKey: id) + } + }, + requestConfirmation: { _ in }, + runLoginFlow: {}) + } + + @MainActor + func presentationContext(provider: UsageProvider, metadata: ProviderMetadata) -> ProviderPresentationContext { + ProviderPresentationContext( + provider: provider, + settings: self.settings, + store: self.store, + metadata: metadata) + } + } + + private final class ProviderSettingsContextState { + var statusByID: [String: String] = [:] + var lastRunAtByID: [String: Date] = [:] } } diff --git a/docs/configuration.md b/docs/configuration.md index 763ffa759e..8d463652e8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -53,7 +53,8 @@ All provider fields are optional unless noted. - `auto` (browser import), `manual` (use `cookieHeader`), `off` (disable cookies) - `cookieHeader`: raw cookie header value (e.g. `key=value; other=...`). - `region`: provider-specific region (e.g. `zai`, `minimax`). -- `workspaceID`: provider-specific workspace/deployment ID (e.g. Azure OpenAI deployment, `opencode`). +- `workspaceID`: provider-specific workspace/deployment/project ID (e.g. Azure OpenAI deployment, OpenAI API project, + `opencode`). - `tokenAccounts`: multi-account tokens for providers in `TokenAccountSupportCatalog`. ## Manual cookies @@ -104,6 +105,18 @@ printf '%s' "$GROQ_API_KEY" | codexbar config set-api-key --provider groq --stdi printf '%s' "$LLM_PROXY_API_KEY" | codexbar config set-api-key --provider llmproxy --stdin ``` +OpenAI API project scoping uses `workspaceID` in config. This maps to `OPENAI_PROJECT_ID` for Admin API usage and is +only applied to the configured OpenAI key, not to selected OpenAI token accounts: + +```json +{ + "id": "openai", + "enabled": true, + "apiKey": "", + "workspaceID": "proj_..." +} +``` + LLM Proxy also needs a base URL. Set `enterpriseHost` in config or `LLM_PROXY_BASE_URL` in the process environment: ```json diff --git a/docs/openai.md b/docs/openai.md index d27dc907a5..6db22965db 100644 --- a/docs/openai.md +++ b/docs/openai.md @@ -15,6 +15,8 @@ CodexBar's OpenAI API provider targets the API Platform organization dashboard, - `GET https://api.openai.com/v1/organization/costs` - `GET https://api.openai.com/v1/organization/usage/completions` - Daily buckets use `bucket_width=1d`, costs are grouped by `line_item`, and completion usage is grouped by `model`. + - Optional project scoping comes from `OPENAI_PROJECT_ID` or `providers[].workspaceID` for `openai`. + Project-scoped requests add `project_ids=` to both Admin API endpoints. 2. Fallback: legacy `GET https://api.openai.com/v1/dashboard/billing/credit_grants` for normal API keys that cannot access organization usage. ## Setup @@ -29,12 +31,29 @@ Settings → Providers → OpenAI writes the same `~/.codexbar/config.json` fiel `OPENAI_API_KEY` because it unlocks organization costs and usage; a normal API key only supports the legacy balance fallback. +To scope Admin API usage to a project, set the OpenAI Project ID field in Settings or add `workspaceID` to the `openai` +provider config: + +```json +{ + "id": "openai", + "apiKey": "", + "workspaceID": "proj_..." +} +``` + +Project scoping is tied to the configured Admin API key. Selected OpenAI token accounts intentionally scrub +`OPENAI_PROJECT_ID`/`workspaceID` so one account cannot inherit another account's project filter. Project-scoped Admin +API failures do not fall back to the legacy billing endpoint, because that endpoint is not project-filtered. + ## Menu display - Admin API data renders inline Today/7d/configured-window KPIs plus a compact spend chart. - The inline usage card opens a hosted chart submenu with daily spend, token, and request trends plus selected-day detail. - Top model and top spend labels come from the configured completion/cost buckets when the Admin API returns them. - Legacy balance data keeps the older available/used credit summary and does not show organization graphs. +- Project-scoped Admin API data labels the account as `Admin API: ` and the organization line as + `Project: `. ## Notes