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)") }}
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)")
}
}