Files
2026-07-13 12:22:33 +08:00

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