548 lines · 18.5 KB · Swift
RawDownload
import Foundation#if canImport(FoundationNetworking)import FoundationNetworking#endif public enum DeepgramUsageError: LocalizedError, Sendable {    case missingAPIKey    case invalidCredentials    case invalidProjectID    case forbidden(String)    case networkError(String)    case apiError(String)    case parseFailed(String)     public var errorDescription: String? {        switch self {        case .missingAPIKey:            "Missing Deepgram API key. Set apiKey in ~/.codexbar/config.json or DEEPGRAM_API_KEY."        case .invalidCredentials:            "Deepgram API key is invalid or expired."        case .invalidProjectID:            "Deepgram project ID is invalid or no projects were returned for this API key."        case let .forbidden(message):            "Deepgram rejected access: \(message)"        case let .networkError(message):            "Deepgram network error: \(message)"        case let .apiError(message):            "Deepgram API error: \(message)"        case let .parseFailed(message):            "Deepgram parse error: \(message)"        }    }} // MARK: - API Responses public struct DeepgramProjectsResponse: Decodable, Sendable {    public let projects: [DeepgramProject]} public struct DeepgramProject: Decodable, Sendable, Equatable {    public let projectID: String    public let name: String?     private enum CodingKeys: String, CodingKey {        case projectID = "project_id"        case name    }     public init(projectID: String, name: String? = nil) {        self.projectID = projectID        self.name = name    }} public struct DeepgramUsageResponse: Decodable, Sendable {    public let start: String?    public let end: String?    public let resolution: DeepgramUsageResolution?    public let results: [DeepgramUsageResult]} public struct DeepgramUsageResolution: Decodable, Sendable {    public let units: String?    public let amount: Int?} public struct DeepgramUsageResult: Codable, Sendable {    public let start: String?    public let end: String?    public let hours: Double?    public let totalHours: Double?    public let agentHours: Double?    public let tokensIn: Int?    public let tokensOut: Int?    public let ttsCharacters: Int?    public let requests: Int?     private enum CodingKeys: String, CodingKey {        case start        case end        case hours        case totalHours = "total_hours"        case agentHours = "agent_hours"        case tokensIn = "tokens_in"        case tokensOut = "tokens_out"        case ttsCharacters = "tts_characters"        case requests    }} // MARK: - Query public struct DeepgramUsageQuery: Sendable {    public var start: String?    public var end: String?     public init(        start: String? = nil,        end: String? = nil)    {        self.start = start        self.end = end    }     public func queryItems() -> [URLQueryItem] {        var items: [URLQueryItem] = []         func add(_ name: String, _ value: String?) {            guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines),                  !value.isEmpty            else {                return            }            items.append(URLQueryItem(name: name, value: value))        }         add("start", self.start)        add("end", self.end)         return items    }} // MARK: - Snapshot public struct DeepgramUsageSnapshot: Codable, Sendable {    public let projectID: String    public let projectName: String?    public let projectCount: Int    public let start: String?    public let end: String?    public let hours: Double    public let totalHours: Double    public let agentHours: Double    public let tokensIn: Int    public let tokensOut: Int    public let ttsCharacters: Int    public let requests: Int    public let updatedAt: Date     public init(        projectID: String,        projectName: String? = nil,        projectCount: Int = 1,        start: String?,        end: String?,        hours: Double,        totalHours: Double,        agentHours: Double = 0,        tokensIn: Int = 0,        tokensOut: Int = 0,        ttsCharacters: Int = 0,        requests: Int,        updatedAt: Date)    {        self.projectID = projectID        self.projectName = projectName        self.projectCount = projectCount        self.start = start        self.end = end        self.hours = hours        self.totalHours = totalHours        self.agentHours = agentHours        self.tokensIn = tokensIn        self.tokensOut = tokensOut        self.ttsCharacters = ttsCharacters        self.requests = requests        self.updatedAt = updatedAt    }} extension DeepgramUsageSnapshot {    public func toUsageSnapshot() -> UsageSnapshot {        let identity = ProviderIdentitySnapshot(            providerID: .deepgram,            accountEmail: nil,            accountOrganization: nil,            loginMethod: self.identityLabel)         return UsageSnapshot(            primary: nil,            secondary: nil,            tertiary: nil,            providerCost: nil,            deepgramUsage: self,            updatedAt: self.updatedAt,            identity: identity)    }     public var displayLines: [String] {        var lines: [String] = []        lines.append("Requests: \(Self.formatInteger(self.requests))")         var usageParts: [String] = []        if self.hours > 0 {            usageParts.append("\(Self.formatDecimal(self.hours)) audio hours")        }        if self.totalHours > 0 {            usageParts.append("\(Self.formatDecimal(self.totalHours)) billable hours")        }        if !usageParts.isEmpty {            lines.append(usageParts.joined(separator: " · "))        }         var modelParts: [String] = []        if self.agentHours > 0 {            modelParts.append("\(Self.formatDecimal(self.agentHours)) agent hours")        }        if self.tokensIn > 0 || self.tokensOut > 0 {            modelParts.append("\(Self.formatInteger(self.tokensIn + self.tokensOut)) tokens")        }        if self.ttsCharacters > 0 {            modelParts.append("\(Self.formatInteger(self.ttsCharacters)) TTS chars")        }        if !modelParts.isEmpty {            lines.append(modelParts.joined(separator: " · "))        }         if let start, let end {            lines.append("Period: \(start) to \(end)")        }         return lines    }     private var identityLabel: String? {        if self.projectCount > 1 {            return "\(self.projectCount) projects"        }        if let projectName = self.projectName?.trimmingCharacters(in: .whitespacesAndNewlines),           !projectName.isEmpty        {            return "Project: \(projectName)"        }        return "Project: \(self.projectID)"    }     fileprivate static func aggregate(        _ snapshots: [DeepgramUsageSnapshot],        updatedAt: Date) throws -> DeepgramUsageSnapshot    {        guard let first = snapshots.first else {            throw DeepgramUsageError.invalidProjectID        }        if snapshots.count == 1 { return first }        return DeepgramUsageSnapshot(            projectID: "all",            projectName: nil,            projectCount: snapshots.count,            start: snapshots.compactMap(\.start).min(),            end: snapshots.compactMap(\.end).max(),            hours: snapshots.reduce(0) { $0 + $1.hours },            totalHours: snapshots.reduce(0) { $0 + $1.totalHours },            agentHours: snapshots.reduce(0) { $0 + $1.agentHours },            tokensIn: snapshots.reduce(0) { $0 + $1.tokensIn },            tokensOut: snapshots.reduce(0) { $0 + $1.tokensOut },            ttsCharacters: snapshots.reduce(0) { $0 + $1.ttsCharacters },            requests: snapshots.reduce(0) { $0 + $1.requests },            updatedAt: updatedAt)    }     private static func formatInteger(_ 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)"    }     private static func formatDecimal(_ value: Double) -> String {        let formatter = NumberFormatter()        formatter.locale = Locale(identifier: "en_US_POSIX")        formatter.numberStyle = .decimal        formatter.usesGroupingSeparator = true        formatter.groupingSeparator = ","        formatter.minimumFractionDigits = value == floor(value) ? 0 : 1        formatter.maximumFractionDigits = 1        return formatter.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value)    }} // MARK: - Fetcher public struct DeepgramUsageFetcher: Sendable {    private static let log = CodexBarLog.logger(LogCategories.deepgramUsage)    private static let defaultBaseURL = URL(string: "https://api.deepgram.com/v1")!     private struct FetchContext {        let apiKey: String        let query: DeepgramUsageQuery        let timeout: TimeInterval        let environment: [String: String]        let transport: ProviderHTTPTransport        let updatedAt: Date    }     public static func fetchUsage(        apiKey: String,        projectID: String? = nil,        query: DeepgramUsageQuery = DeepgramUsageQuery(),        timeout: TimeInterval = 15,        environment: [String: String] = ProcessInfo.processInfo.environment,        transport: ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> DeepgramUsageSnapshot    {        let cleanedAPIKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)        guard !cleanedAPIKey.isEmpty else {            throw DeepgramUsageError.missingAPIKey        }         let updatedAt = Date()        let context = FetchContext(            apiKey: cleanedAPIKey,            query: query,            timeout: timeout,            environment: environment,            transport: transport,            updatedAt: updatedAt)         if let cleanedProjectID = self.cleaned(projectID) {            return try await self.fetchUsage(                project: DeepgramProject(projectID: cleanedProjectID),                context: context)        }         let projects = try await self.listProjects(            apiKey: cleanedAPIKey,            timeout: timeout,            environment: environment,            transport: transport)        guard !projects.isEmpty else {            throw DeepgramUsageError.invalidProjectID        }         var snapshots: [DeepgramUsageSnapshot] = []        snapshots.reserveCapacity(projects.count)        for project in projects {            let snapshot = try await self.fetchUsage(                project: project,                context: context)            snapshots.append(snapshot)        }        return try DeepgramUsageSnapshot.aggregate(snapshots, updatedAt: updatedAt)    }     static func _parseSnapshotForTesting(        _ data: Data,        projectID: String = "project-test",        projectName: String? = nil,        updatedAt: Date = Date()) throws -> DeepgramUsageSnapshot    {        try self.parseUsage(            data: data,            project: DeepgramProject(projectID: projectID, name: projectName),            updatedAt: updatedAt)    }     private static func listProjects(        apiKey: String,        timeout: TimeInterval,        environment: [String: String],        transport: ProviderHTTPTransport) async throws -> [DeepgramProject]    {        let url = self.apiURL(environment: environment).appendingPathComponent("projects")        var request = URLRequest(url: url)        request.httpMethod = "GET"        request.timeoutInterval = timeout        request.setValue("Token \(apiKey)", forHTTPHeaderField: "Authorization")        request.setValue("application/json", forHTTPHeaderField: "Accept")         let response = try await self.perform(request: request, transport: transport)        do {            return try JSONDecoder().decode(DeepgramProjectsResponse.self, from: response.data).projects        } catch {            Self.log.error("Deepgram projects decode failed: \(error.localizedDescription)")            throw DeepgramUsageError.parseFailed(error.localizedDescription)        }    }     private static func fetchUsage(        project: DeepgramProject,        context: FetchContext) async throws -> DeepgramUsageSnapshot    {        let url = try self.usageURL(            projectID: project.projectID,            query: context.query,            environment: context.environment)         var request = URLRequest(url: url)        request.httpMethod = "GET"        request.timeoutInterval = context.timeout        request.setValue("Token \(context.apiKey)", forHTTPHeaderField: "Authorization")        request.setValue("application/json", forHTTPHeaderField: "Accept")         let response = try await self.perform(request: request, transport: context.transport)        return try self.parseUsage(data: response.data, project: project, updatedAt: context.updatedAt)    }     private static func perform(        request: URLRequest,        transport: ProviderHTTPTransport) async throws -> ProviderHTTPResponse    {        let response: ProviderHTTPResponse        do {            response = try await transport.response(for: request)        } catch {            throw DeepgramUsageError.networkError(error.localizedDescription)        }         guard response.statusCode == 200 else {            let summary = self.responseSummary(response.data)            Self.log.error("Deepgram returned HTTP \(response.statusCode): \(summary)")             switch response.statusCode {            case 401:                throw DeepgramUsageError.invalidCredentials            case 403:                throw DeepgramUsageError.forbidden(                    "The API key may not have access to the project or the Management API. HTTP 403: \(summary)")            case 400:                throw DeepgramUsageError.apiError("Bad request. HTTP 400: \(summary)")            default:                throw DeepgramUsageError.apiError("HTTP \(response.statusCode): \(summary)")            }        }         return response    }     private static func usageURL(        projectID: String,        query: DeepgramUsageQuery,        environment: [String: String]) throws -> URL    {        let usageURL = self.apiURL(environment: environment)            .appendingPathComponent("projects")            .appendingPathComponent(projectID)            .appendingPathComponent("usage")            .appendingPathComponent("breakdown")         guard var components = URLComponents(url: usageURL, resolvingAgainstBaseURL: false) else {            throw DeepgramUsageError.networkError("Invalid usage URL")        }         let queryItems = query.queryItems()        if !queryItems.isEmpty {            components.queryItems = queryItems        }         guard let finalURL = components.url else {            throw DeepgramUsageError.networkError("Invalid usage query")        }         return finalURL    }     private static func apiURL(environment: [String: String]) -> URL {        if let raw = environment["DEEPGRAM_API_URL"]?.trimmingCharacters(in: .whitespacesAndNewlines),           !raw.isEmpty,           let url = URL(string: raw)        {            return url        }         return self.defaultBaseURL    }     private static func parseUsage(        data: Data,        project: DeepgramProject,        updatedAt: Date) throws -> DeepgramUsageSnapshot    {        do {            let response = try JSONDecoder().decode(DeepgramUsageResponse.self, from: data)            return DeepgramUsageSnapshot(                projectID: project.projectID,                projectName: project.name,                start: response.start,                end: response.end,                hours: response.results.reduce(0) { $0 + ($1.hours ?? 0) },                totalHours: response.results.reduce(0) { $0 + ($1.totalHours ?? 0) },                agentHours: response.results.reduce(0) { $0 + ($1.agentHours ?? 0) },                tokensIn: response.results.reduce(0) { $0 + ($1.tokensIn ?? 0) },                tokensOut: response.results.reduce(0) { $0 + ($1.tokensOut ?? 0) },                ttsCharacters: response.results.reduce(0) { $0 + ($1.ttsCharacters ?? 0) },                requests: response.results.reduce(0) { $0 + ($1.requests ?? 0) },                updatedAt: updatedAt)        } catch let error as DecodingError {            Self.log.error("Deepgram decoding error: \(error.localizedDescription)")            Self.log.error("Deepgram raw response: \(self.responseSummary(data))")            throw DeepgramUsageError.parseFailed(error.localizedDescription)        } catch {            Self.log.error("Deepgram parse error: \(error.localizedDescription)")            Self.log.error("Deepgram raw response: \(self.responseSummary(data))")            throw DeepgramUsageError.parseFailed(error.localizedDescription)        }    } 

Lines 1–500 of 548.