import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif public enum DeepgramUsageError: LocalizedError, Sendable { case missingAPIKey case invalidEndpointOverride(String) 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 let .invalidEndpointOverride(key): "Deepgram endpoint override \(key) must use HTTPS or a bare host." 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 { public static let apiURLKey = "DEEPGRAM_API_URL" 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 } try self.validateEndpointOverrides(environment: environment) 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 = self.cleaned(environment[self.apiURLKey]), let url = ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: raw) { return url } return self.defaultBaseURL } public static func validateEndpointOverrides(environment: [String: String] = ProcessInfo.processInfo .environment) throws { guard let raw = self.cleaned(environment[self.apiURLKey]) else { return } guard ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: raw) == nil else { return } throw DeepgramUsageError.invalidEndpointOverride(self.apiURLKey) } 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) } } private static func cleaned(_ raw: String?) -> String? { guard let value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil } return value } private static func responseSummary(_ data: Data) -> String { guard !data.isEmpty else { return "empty body" } guard let text = String(data: data, encoding: .utf8) else { return "non-text body (\(data.count) bytes)" } let cleaned = text .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) .trimmingCharacters(in: .whitespacesAndNewlines) guard !cleaned.isEmpty else { return "empty body" } let maxLength = 240 guard cleaned.count > maxLength else { return self.redact(cleaned) } let index = cleaned.index(cleaned.startIndex, offsetBy: maxLength) return self.redact("\(cleaned[.. String { let replacements: [(String, String)] = [ (#"(?i)(token\s+)[A-Za-z0-9._\-]+"#, "$1[REDACTED]"), (#"(?i)(dg_[A-Za-z0-9._\-]+)"#, "[REDACTED]"), ( #"(?i)(\"(?:api_?key|authorization|token|access_token|refresh_token)\"\s*:\s*\")([^\"]+)(\")"#, "$1[REDACTED]$3"), ] return replacements.reduce(text) { partial, replacement in partial.replacingOccurrences( of: replacement.0, with: replacement.1, options: .regularExpression) } } }