562 lines
19 KiB
Swift
562 lines
19 KiB
Swift
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[..<index])... [truncated]")
|
|
}
|
|
|
|
private static func redact(_ text: String) -> 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)
|
|
}
|
|
}
|
|
}
|