import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif // MARK: - API response types public struct VeniceBalanceResponse: Decodable, Sendable { public let canConsume: Bool public let consumptionCurrency: String? public let balances: VeniceBalances public let diemEpochAllocation: Double? enum CodingKeys: String, CodingKey { case canConsume case consumptionCurrency case balances case diemEpochAllocation } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.canConsume = try container.decode(Bool.self, forKey: .canConsume) self.consumptionCurrency = try container.decodeIfPresent(String.self, forKey: .consumptionCurrency) self.balances = try container.decode(VeniceBalances.self, forKey: .balances) self.diemEpochAllocation = try container.decodeFlexibleDoubleIfPresent(forKey: .diemEpochAllocation) } } public struct VeniceBalances: Decodable, Sendable { public let diem: Double? public let usd: Double? enum CodingKeys: String, CodingKey { case diem case usd } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.diem = try container.decodeFlexibleDoubleIfPresent(forKey: .diem) self.usd = try container.decodeFlexibleDoubleIfPresent(forKey: .usd) } } // MARK: - Domain snapshot public struct VeniceUsageSnapshot: Sendable { public let canConsume: Bool public let consumptionCurrency: String? public let diemBalance: Double? public let usdBalance: Double? public let diemEpochAllocation: Double? public let updatedAt: Date public init( canConsume: Bool, consumptionCurrency: String?, diemBalance: Double?, usdBalance: Double?, diemEpochAllocation: Double?, updatedAt: Date) { self.canConsume = canConsume self.consumptionCurrency = consumptionCurrency self.diemBalance = diemBalance self.usdBalance = usdBalance self.diemEpochAllocation = diemEpochAllocation self.updatedAt = updatedAt } public func toUsageSnapshot() -> UsageSnapshot { let balanceDetail: String let usedPercent: Double let activeCurrency = self.consumptionCurrency?.uppercased() if !self.canConsume { balanceDetail = "Balance unavailable for API calls" usedPercent = 100 } else if activeCurrency == "USD", let usd = self.usdBalance, usd > 0 { let usdStr = String(format: "%.2f", usd) balanceDetail = "$\(usdStr) USD remaining" usedPercent = 0 } else if activeCurrency != "USD", let diem = self.diemBalance, let allocation = self.diemEpochAllocation, allocation > 0 { // DIEM balance with epoch allocation let remaining = diem let usedAmount = allocation - remaining let used = clamp(usedAmount / allocation * 100, min: 0, max: 100) usedPercent = used let allocationStr = String(format: "%.2f", allocation) let remainingStr = String(format: "%.2f", remaining) balanceDetail = "DIEM \(remainingStr) / \(allocationStr) epoch allocation" } else if activeCurrency == "DIEM", let diem = self.diemBalance, diem > 0 { let diemStr = String(format: "%.2f", diem) balanceDetail = "DIEM \(diemStr) remaining" usedPercent = 0 } else if let diem = self.diemBalance, diem > 0 { // DIEM balance without allocation let diemStr = String(format: "%.2f", diem) balanceDetail = "DIEM \(diemStr) remaining" usedPercent = 0 } else if let usd = self.usdBalance, usd > 0 { // USD balance let usdStr = String(format: "%.2f", usd) balanceDetail = "$\(usdStr) USD remaining" usedPercent = 0 } else { balanceDetail = "No Venice API balance available" usedPercent = 100 } let identity = ProviderIdentitySnapshot( providerID: .venice, accountEmail: nil, accountOrganization: nil, loginMethod: nil) let balanceWindow = RateWindow( usedPercent: usedPercent, windowMinutes: nil, resetsAt: nil, resetDescription: balanceDetail) return UsageSnapshot( primary: balanceWindow, secondary: nil, tertiary: nil, providerCost: nil, updatedAt: self.updatedAt, identity: identity) } } // MARK: - Errors public enum VeniceUsageError: LocalizedError, Sendable { case missingCredentials case networkError(String) case apiError(String) case parseFailed(String) public var errorDescription: String? { switch self { case .missingCredentials: "Missing Venice API key." case let .networkError(message): "Venice network error: \(message)" case let .apiError(message): "Venice API error: \(message)" case let .parseFailed(message): "Failed to parse Venice response: \(message)" } } } // MARK: - Fetcher public struct VeniceUsageFetcher: Sendable { private static let log = CodexBarLog.logger(LogCategories.veniceUsage) private static let balanceURL = URL(string: "https://api.venice.ai/api/v1/billing/balance")! private static let timeoutSeconds: TimeInterval = 15 public static func fetchUsage(apiKey: String) async throws -> VeniceUsageSnapshot { guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw VeniceUsageError.missingCredentials } var request = URLRequest(url: self.balanceURL) request.httpMethod = "GET" request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") request.timeoutInterval = Self.timeoutSeconds let response = try await ProviderHTTPClient.shared.response(for: request) guard response.statusCode == 200 else { Self.log.error("Venice API returned \(response.statusCode)") throw VeniceUsageError.apiError("HTTP \(response.statusCode)") } return try Self.parseSnapshot(data: response.data) } static func _parseSnapshotForTesting(_ data: Data) throws -> VeniceUsageSnapshot { try self.parseSnapshot(data: data) } private static func parseSnapshot(data: Data) throws -> VeniceUsageSnapshot { let decoded: VeniceBalanceResponse do { decoded = try JSONDecoder().decode(VeniceBalanceResponse.self, from: data) } catch { throw VeniceUsageError.parseFailed(error.localizedDescription) } return VeniceUsageSnapshot( canConsume: decoded.canConsume, consumptionCurrency: decoded.consumptionCurrency, diemBalance: decoded.balances.diem, usdBalance: decoded.balances.usd, diemEpochAllocation: decoded.diemEpochAllocation, updatedAt: Date()) } } // MARK: - Helper private func clamp(_ value: Double, min: Double, max: Double) -> Double { Swift.min(Swift.max(value, min), max) } extension KeyedDecodingContainer { fileprivate func decodeFlexibleDoubleIfPresent(forKey key: K) throws -> Double? { if try self.decodeNil(forKey: key) { return nil } if let value = try? self.decode(Double.self, forKey: key) { return value } if let stringValue = try? self.decode(String.self, forKey: key) { let trimmed = stringValue.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } if let parsed = Double(trimmed) { return parsed } throw DecodingError.dataCorruptedError( forKey: key, in: self, debugDescription: "Expected a numeric string for \(key.stringValue), got '\(stringValue)'") } throw DecodingError.dataCorruptedError( forKey: key, in: self, debugDescription: "Expected a number or numeric string for \(key.stringValue)") } }