import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif public struct OpenAIAPICreditGrant: Decodable, Sendable { public let grantAmount: Double? public let usedAmount: Double? public let expiresAt: Date? private enum CodingKeys: String, CodingKey { case grantAmount = "grant_amount" case usedAmount = "used_amount" case expiresAt = "expires_at" } } public struct OpenAIAPICreditGrantsList: Decodable, Sendable { public let data: [OpenAIAPICreditGrant] } public struct OpenAIAPICreditGrantsResponse: Decodable, Sendable { public let totalGranted: Double public let totalUsed: Double public let totalAvailable: Double public let grants: OpenAIAPICreditGrantsList? private enum CodingKeys: String, CodingKey { case totalGranted = "total_granted" case totalUsed = "total_used" case totalAvailable = "total_available" case grants } } public enum OpenAIAPICreditBalanceError: LocalizedError, Sendable, Equatable { case missingCredentials case networkError(String) case apiError(Int) case unauthorized case forbidden case parseFailed(String) public var errorDescription: String? { switch self { case .missingCredentials: "Missing OpenAI API key." case let .networkError(message): "OpenAI API credit balance network error: \(message)" case let .apiError(statusCode): "OpenAI API credit balance error: HTTP \(statusCode)" case .unauthorized: "OpenAI rejected this key for credit balance access (HTTP 401). Use an organization Admin API key " + "for usage; project and service-account keys do not provide organization usage access." case .forbidden: "OpenAI API credit balance endpoint returned HTTP 403. Use a legacy/user API key with billing access; " + "project keys may not expose credit grants." case let .parseFailed(message): "Failed to parse OpenAI API credit balance: \(message)" } } } public struct OpenAIAPICreditBalanceSnapshot: Sendable { public let totalGranted: Double public let totalUsed: Double public let totalAvailable: Double public let nextGrantExpiry: Date? public let updatedAt: Date public init( totalGranted: Double, totalUsed: Double, totalAvailable: Double, nextGrantExpiry: Date?, updatedAt: Date = Date()) { self.totalGranted = totalGranted self.totalUsed = totalUsed self.totalAvailable = totalAvailable self.nextGrantExpiry = nextGrantExpiry self.updatedAt = updatedAt } public func toUsageSnapshot() -> UsageSnapshot { let usedPercent: Double = if self.totalGranted > 0 { min(100, max(0, (self.totalUsed / self.totalGranted) * 100)) } else { self.totalAvailable > 0 ? 0 : 100 } let primary = RateWindow( usedPercent: usedPercent, windowMinutes: nil, resetsAt: self.nextGrantExpiry, resetDescription: "\(Self.formatUSD(self.totalAvailable)) available") return UsageSnapshot( primary: primary, secondary: nil, providerCost: ProviderCostSnapshot( used: max(0, self.totalUsed), limit: max(0, self.totalGranted), currencyCode: "USD", period: "API credits", resetsAt: self.nextGrantExpiry, updatedAt: self.updatedAt), updatedAt: self.updatedAt, identity: ProviderIdentitySnapshot( providerID: .openai, accountEmail: nil, accountOrganization: nil, loginMethod: "API balance: \(Self.formatUSD(self.totalAvailable))")) } private static func formatUSD(_ value: Double) -> String { UsageFormatter.currencyString(max(0, value), currencyCode: "USD") } } public enum OpenAIAPICreditBalanceFetcher { public static let creditGrantsURL = URL(string: "https://api.openai.com/v1/dashboard/billing/credit_grants")! private static let timeoutSeconds: TimeInterval = 15 public static func fetchBalance( apiKey: String, url: URL = Self.creditGrantsURL, session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, now: Date = Date()) async throws -> OpenAIAPICreditBalanceSnapshot { let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { throw OpenAIAPICreditBalanceError.missingCredentials } var request = URLRequest(url: url) request.httpMethod = "GET" request.timeoutInterval = Self.timeoutSeconds request.setValue("Bearer \(trimmed)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") let response: ProviderHTTPResponse do { response = try await transport.response(for: request) } catch { throw OpenAIAPICreditBalanceError.networkError(error.localizedDescription) } switch response.statusCode { case 200: break case 401: throw OpenAIAPICreditBalanceError.unauthorized case 403: throw OpenAIAPICreditBalanceError.forbidden default: throw OpenAIAPICreditBalanceError.apiError(response.statusCode) } return try self.parseSnapshot(response.data, now: now) } public static func _parseSnapshotForTesting( _ data: Data, now: Date = Date()) throws -> OpenAIAPICreditBalanceSnapshot { try self.parseSnapshot(data, now: now) } private static func parseSnapshot(_ data: Data, now: Date) throws -> OpenAIAPICreditBalanceSnapshot { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .secondsSince1970 let decoded: OpenAIAPICreditGrantsResponse do { decoded = try decoder.decode(OpenAIAPICreditGrantsResponse.self, from: data) } catch { throw OpenAIAPICreditBalanceError.parseFailed(error.localizedDescription) } let nextExpiry = decoded.grants?.data .compactMap(\.expiresAt) .filter { $0 > now } .min() return OpenAIAPICreditBalanceSnapshot( totalGranted: decoded.totalGranted, totalUsed: decoded.totalUsed, totalAvailable: decoded.totalAvailable, nextGrantExpiry: nextExpiry, updatedAt: now) } }