vOOice/VoiceInk/Services/CloudTranscription/GroqTranscriptionService.swift
Beingpax 948033ac28 Migrate API key storage to Keychain with iCloud sync
Move API keys from UserDefaults to secure Keychain storage. Add KeychainService and APIKeyManager for centralized key management. Enable iCloud Keychain sync for cross-device sharing between macOS and iOS.
2026-01-05 22:28:34 +05:45

182 lines
8.5 KiB
Swift

import Foundation
import os
class GroqTranscriptionService {
private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "GroqService")
private let baseTimeout: TimeInterval = 120
private let maxRetries: Int = 2
private let initialRetryDelay: TimeInterval = 1.0
func transcribe(audioURL: URL, model: any TranscriptionModel) async throws -> String {
return try await transcribeWithRetry(audioURL: audioURL, model: model)
}
private func makeTranscriptionRequest(audioURL: URL, model: any TranscriptionModel) async throws -> String {
let config = try getAPIConfig(for: model)
let boundary = "Boundary-\(UUID().uuidString)"
var request = URLRequest(url: config.url)
request.httpMethod = "POST"
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(config.apiKey)", forHTTPHeaderField: "Authorization")
request.timeoutInterval = baseTimeout
let body = try createOpenAICompatibleRequestBody(audioURL: audioURL, modelName: config.modelName, boundary: boundary)
let (data, response) = try await URLSession.shared.upload(for: request, from: body)
guard let httpResponse = response as? HTTPURLResponse else {
throw CloudTranscriptionError.networkError(URLError(.badServerResponse))
}
if !(200...299).contains(httpResponse.statusCode) {
let errorMessage = String(data: data, encoding: .utf8) ?? "No error message"
logger.error("Groq API request failed with status \(httpResponse.statusCode): \(errorMessage, privacy: .public)")
throw CloudTranscriptionError.apiRequestFailed(statusCode: httpResponse.statusCode, message: errorMessage)
}
do {
let transcriptionResponse = try JSONDecoder().decode(TranscriptionResponse.self, from: data)
return transcriptionResponse.text
} catch {
logger.error("Failed to decode Groq API response: \(error.localizedDescription)")
throw CloudTranscriptionError.noTranscriptionReturned
}
}
private func transcribeWithRetry(audioURL: URL, model: any TranscriptionModel) async throws -> String {
var retries = 0
var currentDelay = initialRetryDelay
while retries < self.maxRetries {
do {
return try await makeTranscriptionRequest(audioURL: audioURL, model: model)
} catch let error as CloudTranscriptionError {
switch error {
case .networkError:
retries += 1
if retries < self.maxRetries {
logger.warning("Transcription request failed, retrying in \(currentDelay)s... (Attempt \(retries)/\(self.maxRetries))")
try await Task.sleep(nanoseconds: UInt64(currentDelay * 1_000_000_000))
currentDelay *= 2
} else {
logger.error("Transcription request failed after \(self.maxRetries) retries.")
throw error
}
case .apiRequestFailed(let statusCode, _):
if (500...599).contains(statusCode) || statusCode == 429 {
retries += 1
if retries < self.maxRetries {
logger.warning("Transcription request failed with status \(statusCode), retrying in \(currentDelay)s... (Attempt \(retries)/\(self.maxRetries))")
try await Task.sleep(nanoseconds: UInt64(currentDelay * 1_000_000_000))
currentDelay *= 2
} else {
logger.error("Transcription request failed after \(self.maxRetries) retries.")
throw error
}
} else {
throw error
}
default:
throw error
}
} catch {
let nsError = error as NSError
if nsError.domain == NSURLErrorDomain &&
[NSURLErrorNotConnectedToInternet, NSURLErrorTimedOut, NSURLErrorNetworkConnectionLost].contains(nsError.code) {
retries += 1
if retries < self.maxRetries {
logger.warning("Transcription request failed with network error, retrying in \(currentDelay)s... (Attempt \(retries)/\(self.maxRetries))")
try await Task.sleep(nanoseconds: UInt64(currentDelay * 1_000_000_000))
currentDelay *= 2
} else {
logger.error("Transcription request failed after \(self.maxRetries) retries with network error.")
throw CloudTranscriptionError.networkError(error)
}
} else {
throw error
}
}
}
throw CloudTranscriptionError.noTranscriptionReturned
}
private func getAPIConfig(for model: any TranscriptionModel) throws -> APIConfig {
guard let apiKey = APIKeyManager.shared.getAPIKey(forProvider: "Groq"), !apiKey.isEmpty else {
throw CloudTranscriptionError.missingAPIKey
}
guard let apiURL = URL(string: "https://api.groq.com/openai/v1/audio/transcriptions") else {
throw NSError(domain: "GroqTranscriptionService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid API URL"])
}
return APIConfig(url: apiURL, apiKey: apiKey, modelName: model.name)
}
private func createOpenAICompatibleRequestBody(audioURL: URL, modelName: String, boundary: String) throws -> Data {
var body = Data()
let crlf = "\r\n"
guard let audioData = try? Data(contentsOf: audioURL) else {
throw CloudTranscriptionError.audioFileNotFound
}
let selectedLanguage = UserDefaults.standard.string(forKey: "SelectedLanguage") ?? "auto"
let prompt = UserDefaults.standard.string(forKey: "TranscriptionPrompt") ?? ""
body.append("--\(boundary)\(crlf)".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(audioURL.lastPathComponent)\"\(crlf)".data(using: .utf8)!)
body.append("Content-Type: audio/wav\(crlf)\(crlf)".data(using: .utf8)!)
body.append(audioData)
body.append(crlf.data(using: .utf8)!)
body.append("--\(boundary)\(crlf)".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"model\"\(crlf)\(crlf)".data(using: .utf8)!)
body.append(modelName.data(using: .utf8)!)
body.append(crlf.data(using: .utf8)!)
if selectedLanguage != "auto", !selectedLanguage.isEmpty {
body.append("--\(boundary)\(crlf)".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"language\"\(crlf)\(crlf)".data(using: .utf8)!)
body.append(selectedLanguage.data(using: .utf8)!)
body.append(crlf.data(using: .utf8)!)
}
// Include prompt for OpenAI-compatible APIs
if !prompt.isEmpty {
body.append("--\(boundary)\(crlf)".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"prompt\"\(crlf)\(crlf)".data(using: .utf8)!)
body.append(prompt.data(using: .utf8)!)
body.append(crlf.data(using: .utf8)!)
}
body.append("--\(boundary)\(crlf)".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"response_format\"\(crlf)\(crlf)".data(using: .utf8)!)
body.append("json".data(using: .utf8)!)
body.append(crlf.data(using: .utf8)!)
body.append("--\(boundary)\(crlf)".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"temperature\"\(crlf)\(crlf)".data(using: .utf8)!)
body.append("0".data(using: .utf8)!)
body.append(crlf.data(using: .utf8)!)
body.append("--\(boundary)--\(crlf)".data(using: .utf8)!)
return body
}
private struct APIConfig {
let url: URL
let apiKey: String
let modelName: String
}
private struct TranscriptionResponse: Decodable {
let text: String
let language: String?
let duration: Double?
let x_groq: GroqMetadata?
struct GroqMetadata: Decodable {
let id: String?
}
}
}