253 lines
9.2 KiB
Swift
253 lines
9.2 KiB
Swift
import Foundation
|
|
#if canImport(FoundationNetworking)
|
|
import FoundationNetworking
|
|
#endif
|
|
|
|
public struct ElevenLabsOverage: Codable, Sendable, Equatable {
|
|
public let amount: String?
|
|
public let currency: String?
|
|
}
|
|
|
|
public struct ElevenLabsSubscriptionResponse: Decodable, Sendable {
|
|
public let tier: String?
|
|
public let characterCount: Int
|
|
public let characterLimit: Int
|
|
public let voiceSlotsUsed: Int?
|
|
public let professionalVoiceSlotsUsed: Int?
|
|
public let voiceLimit: Int?
|
|
public let professionalVoiceLimit: Int?
|
|
public let currentOverage: ElevenLabsOverage?
|
|
public let status: String?
|
|
public let nextCharacterCountResetUnix: Int?
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case tier
|
|
case characterCount = "character_count"
|
|
case characterLimit = "character_limit"
|
|
case voiceSlotsUsed = "voice_slots_used"
|
|
case professionalVoiceSlotsUsed = "professional_voice_slots_used"
|
|
case voiceLimit = "voice_limit"
|
|
case professionalVoiceLimit = "professional_voice_limit"
|
|
case currentOverage = "current_overage"
|
|
case status
|
|
case nextCharacterCountResetUnix = "next_character_count_reset_unix"
|
|
}
|
|
}
|
|
|
|
public struct ElevenLabsUsageSnapshot: Codable, Sendable, Equatable {
|
|
public let tier: String?
|
|
public let characterCount: Int
|
|
public let characterLimit: Int
|
|
public let voiceSlotsUsed: Int?
|
|
public let professionalVoiceSlotsUsed: Int?
|
|
public let voiceLimit: Int?
|
|
public let professionalVoiceLimit: Int?
|
|
public let currentOverage: ElevenLabsOverage?
|
|
public let status: String?
|
|
public let resetsAt: Date?
|
|
public let updatedAt: Date
|
|
|
|
public init(
|
|
tier: String?,
|
|
characterCount: Int,
|
|
characterLimit: Int,
|
|
voiceSlotsUsed: Int?,
|
|
professionalVoiceSlotsUsed: Int?,
|
|
voiceLimit: Int?,
|
|
professionalVoiceLimit: Int?,
|
|
currentOverage: ElevenLabsOverage?,
|
|
status: String?,
|
|
resetsAt: Date?,
|
|
updatedAt: Date)
|
|
{
|
|
self.tier = tier
|
|
self.characterCount = characterCount
|
|
self.characterLimit = characterLimit
|
|
self.voiceSlotsUsed = voiceSlotsUsed
|
|
self.professionalVoiceSlotsUsed = professionalVoiceSlotsUsed
|
|
self.voiceLimit = voiceLimit
|
|
self.professionalVoiceLimit = professionalVoiceLimit
|
|
self.currentOverage = currentOverage
|
|
self.status = status
|
|
self.resetsAt = resetsAt
|
|
self.updatedAt = updatedAt
|
|
}
|
|
|
|
public var usedPercent: Double {
|
|
guard self.characterLimit > 0 else { return 0 }
|
|
return max(0, Double(self.characterCount) / Double(self.characterLimit) * 100)
|
|
}
|
|
|
|
public var remainingCharacters: Int {
|
|
max(0, self.characterLimit - self.characterCount)
|
|
}
|
|
|
|
public func toUsageSnapshot() -> UsageSnapshot {
|
|
let primary = RateWindow(
|
|
usedPercent: self.usedPercent,
|
|
windowMinutes: nil,
|
|
resetsAt: self.resetsAt,
|
|
resetDescription: self.characterSummary)
|
|
let extraWindows = self.voiceWindows()
|
|
let identity = ProviderIdentitySnapshot(
|
|
providerID: .elevenlabs,
|
|
accountEmail: nil,
|
|
accountOrganization: nil,
|
|
loginMethod: self.displayTier)
|
|
|
|
return UsageSnapshot(
|
|
primary: primary,
|
|
secondary: nil,
|
|
tertiary: nil,
|
|
extraRateWindows: extraWindows.isEmpty ? nil : extraWindows,
|
|
updatedAt: self.updatedAt,
|
|
identity: identity)
|
|
}
|
|
|
|
private var displayTier: String? {
|
|
guard let tier = tier?.trimmingCharacters(in: .whitespacesAndNewlines), !tier.isEmpty else {
|
|
return status
|
|
}
|
|
let statusSuffix = if let status, !status.isEmpty, status.lowercased() != "active" {
|
|
" · \(status)"
|
|
} else {
|
|
""
|
|
}
|
|
return "\(tier.replacingOccurrences(of: "_", with: " ").capitalized)\(statusSuffix)"
|
|
}
|
|
|
|
private var characterSummary: String {
|
|
"\(Self.formatCount(self.characterCount)) / \(Self.formatCount(self.characterLimit)) credits"
|
|
}
|
|
|
|
private func voiceWindows() -> [NamedRateWindow] {
|
|
var windows: [NamedRateWindow] = []
|
|
if let used = voiceSlotsUsed, let limit = voiceLimit, limit > 0 {
|
|
windows.append(NamedRateWindow(
|
|
id: "voice-slots",
|
|
title: "Voice slots",
|
|
window: RateWindow(
|
|
usedPercent: Double(used) / Double(limit) * 100,
|
|
windowMinutes: nil,
|
|
resetsAt: nil,
|
|
resetDescription: "\(used) / \(limit)")))
|
|
}
|
|
if let used = professionalVoiceSlotsUsed, let limit = professionalVoiceLimit, limit > 0 {
|
|
windows.append(NamedRateWindow(
|
|
id: "professional-voices",
|
|
title: "Professional voices",
|
|
window: RateWindow(
|
|
usedPercent: Double(used) / Double(limit) * 100,
|
|
windowMinutes: nil,
|
|
resetsAt: nil,
|
|
resetDescription: "\(used) / \(limit)")))
|
|
}
|
|
return windows
|
|
}
|
|
|
|
private static func formatCount(_ value: Int) -> String {
|
|
let formatter = NumberFormatter()
|
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
formatter.numberStyle = .decimal
|
|
formatter.usesGroupingSeparator = true
|
|
formatter.groupingSeparator = ","
|
|
formatter.maximumFractionDigits = 0
|
|
return formatter.string(from: NSNumber(value: value)) ?? "\(value)"
|
|
}
|
|
}
|
|
|
|
public enum ElevenLabsUsageError: LocalizedError, Sendable {
|
|
case missingCredentials
|
|
case networkError(String)
|
|
case apiError(String)
|
|
case parseFailed(String)
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .missingCredentials:
|
|
"Missing ElevenLabs API key. Set apiKey in ~/.codexbar/config.json or ELEVENLABS_API_KEY."
|
|
case let .networkError(message):
|
|
"ElevenLabs network error: \(message)"
|
|
case let .apiError(message):
|
|
"ElevenLabs API error: \(message)"
|
|
case let .parseFailed(message):
|
|
"Failed to parse ElevenLabs response: \(message)"
|
|
}
|
|
}
|
|
}
|
|
|
|
public struct ElevenLabsUsageFetcher: Sendable {
|
|
private static let log = CodexBarLog.logger(LogCategories.elevenLabsUsage)
|
|
private static let timeoutSeconds: TimeInterval = 15
|
|
|
|
public static func fetchUsage(
|
|
apiKey: String,
|
|
environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> ElevenLabsUsageSnapshot
|
|
{
|
|
let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else {
|
|
throw ElevenLabsUsageError.missingCredentials
|
|
}
|
|
try ElevenLabsSettingsReader.validateEndpointOverrides(environment: environment)
|
|
|
|
let url = Self.subscriptionURL(baseURL: ElevenLabsSettingsReader.apiURL(environment: environment))
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "GET"
|
|
request.setValue(trimmed, forHTTPHeaderField: "xi-api-key")
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
request.timeoutInterval = Self.timeoutSeconds
|
|
|
|
let response = try await ProviderHTTPClient.shared.response(for: request)
|
|
switch response.statusCode {
|
|
case 200:
|
|
return try Self.parseSnapshot(data: response.data, updatedAt: Date())
|
|
case 401, 403:
|
|
throw ElevenLabsUsageError.missingCredentials
|
|
default:
|
|
Self.log.error("ElevenLabs API returned \(response.statusCode)")
|
|
throw ElevenLabsUsageError.apiError("HTTP \(response.statusCode)")
|
|
}
|
|
}
|
|
|
|
static func _parseSnapshotForTesting(_ data: Data, updatedAt: Date) throws -> ElevenLabsUsageSnapshot {
|
|
try self.parseSnapshot(data: data, updatedAt: updatedAt)
|
|
}
|
|
|
|
private static func parseSnapshot(data: Data, updatedAt: Date) throws -> ElevenLabsUsageSnapshot {
|
|
let decoded: ElevenLabsSubscriptionResponse
|
|
do {
|
|
decoded = try JSONDecoder().decode(ElevenLabsSubscriptionResponse.self, from: data)
|
|
} catch {
|
|
throw ElevenLabsUsageError.parseFailed(error.localizedDescription)
|
|
}
|
|
|
|
let resetsAt = decoded.nextCharacterCountResetUnix.map {
|
|
Date(timeIntervalSince1970: TimeInterval($0))
|
|
}
|
|
|
|
return ElevenLabsUsageSnapshot(
|
|
tier: decoded.tier,
|
|
characterCount: decoded.characterCount,
|
|
characterLimit: decoded.characterLimit,
|
|
voiceSlotsUsed: decoded.voiceSlotsUsed,
|
|
professionalVoiceSlotsUsed: decoded.professionalVoiceSlotsUsed,
|
|
voiceLimit: decoded.voiceLimit,
|
|
professionalVoiceLimit: decoded.professionalVoiceLimit,
|
|
currentOverage: decoded.currentOverage,
|
|
status: decoded.status,
|
|
resetsAt: resetsAt,
|
|
updatedAt: updatedAt)
|
|
}
|
|
|
|
private static func subscriptionURL(baseURL: URL) -> URL {
|
|
var url = baseURL
|
|
let pathComponents = url.path.split(separator: "/")
|
|
if pathComponents.last == "v1" {
|
|
url.append(path: "user/subscription")
|
|
} else {
|
|
url.append(path: "v1/user/subscription")
|
|
}
|
|
return url
|
|
}
|
|
}
|