feat: API key validation and websocket error handling (#27)

This commit is contained in:
Owen Gretzinger
2025-07-15 22:29:00 -04:00
committed by GitHub
parent 8f72cbb5c7
commit fb649904a4
5 changed files with 446 additions and 28 deletions
+94 -20
View File
@@ -10,11 +10,15 @@ import ScreenCaptureKit
class AudioManager: NSObject, ObservableObject {
@Published var transcriptChunks: [TranscriptChunk] = []
@Published var isRecording = false
@Published var errorMessage: String?
private var audioEngine = AVAudioEngine()
private var micSocketTask: URLSessionWebSocketTask?
private var systemSocketTask: URLSessionWebSocketTask?
private let realtimeURL = URL(string: "wss://api.openai.com/v1/realtime?intent=transcription")!
// Unique identifier for the current recording session
private var sessionID = UUID()
// ScreenCaptureKit properties
private var stream: SCStream?
@@ -42,6 +46,14 @@ class AudioManager: NSObject, ObservableObject {
func startRecording() {
print("Starting recording...")
// Bump session ID so any old async callbacks can be ignored
sessionID = UUID()
// Clear any previous errors
DispatchQueue.main.async {
self.errorMessage = nil
}
// First ensure everything is stopped and cleaned up
stopRecordingInternal()
@@ -76,9 +88,7 @@ class AudioManager: NSObject, ObservableObject {
systemSocketTask = nil
// Reset state
DispatchQueue.main.async {
self.isRecording = false
}
// (isRecording already cleared in stopRecording)
print("Internal cleanup completed")
}
@@ -238,6 +248,8 @@ class AudioManager: NSObject, ObservableObject {
}
func stopRecording() {
// Immediately mark as not recording to prevent stale callbacks
self.isRecording = false
print("Stopping recording...")
// Stop system audio capture
@@ -256,10 +268,6 @@ class AudioManager: NSObject, ObservableObject {
systemSocketTask?.cancel(with: .normalClosure, reason: nil)
systemSocketTask = nil
DispatchQueue.main.async {
self.isRecording = false
}
print("Recording stopped")
}
@@ -295,7 +303,11 @@ class AudioManager: NSObject, ObservableObject {
private func connectToOpenAIRealtime(source: AudioSource) {
guard let key = KeychainHelper.shared.getAPIKey(), !key.isEmpty else {
print("❌ No OpenAI key found")
let errorMsg = ErrorMessage.noAPIKey
print("\(errorMsg)")
DispatchQueue.main.async {
self.errorMessage = errorMsg
}
return
}
@@ -305,7 +317,21 @@ class AudioManager: NSObject, ObservableObject {
request.addValue("realtime=v1", forHTTPHeaderField: "OpenAI-Beta")
let task = session.webSocketTask(with: request)
// Add connection monitoring
task.resume()
let thisSession = sessionID
// Monitor connection state (ignore if session changed or recording stopped)
DispatchQueue.main.asyncAfter(deadline: .now() + 10) { [weak self, weak task] in
guard let self = self, self.sessionID == thisSession, self.isRecording else { return }
guard let task = task, task.state != .running else { return }
let errorMsg = ErrorMessage.connectionTimeout
print("\(errorMsg)")
DispatchQueue.main.async {
self.errorMessage = errorMsg
}
}
// Send initial configuration
let config: [String: Any] = [
@@ -328,14 +354,29 @@ class AudioManager: NSObject, ObservableObject {
do {
let jsonData = try JSONSerialization.data(withJSONObject: config)
if let jsonStr = String(data: jsonData, encoding: .utf8) {
task.send(.string(jsonStr)) { error in
task.send(.string(jsonStr)) { [weak self] error in
if let error = error {
print("❌ Config send error: \(error)")
guard let self = self, self.sessionID == thisSession else { return }
// Ignore cancellation errors, which are expected when stopping a session.
if (error as? URLError)?.code == .cancelled {
return
}
let errorMsg = "\(ErrorMessage.configurationFailed): \(ErrorHandler.shared.handleError(error))"
print("\(errorMsg)")
DispatchQueue.main.async {
self.errorMessage = errorMsg
}
}
}
}
} catch {
print("❌ Config JSON error: \(error)")
let errorMsg = "\(ErrorMessage.configurationFailed): \(ErrorHandler.shared.handleError(error))"
print("\(errorMsg)")
DispatchQueue.main.async {
self.errorMessage = errorMsg
}
}
switch source {
@@ -345,11 +386,11 @@ class AudioManager: NSObject, ObservableObject {
systemSocketTask = task
}
receiveMessage(for: source)
receiveMessage(for: source, sessionID: thisSession)
print("🌐 Connected to OpenAI Realtime (\(source))")
}
private func receiveMessage(for source: AudioSource) {
private func receiveMessage(for source: AudioSource, sessionID: UUID) {
let task: URLSessionWebSocketTask? = (source == .mic) ? micSocketTask : systemSocketTask
task?.receive { [weak self] result in
switch result {
@@ -362,18 +403,44 @@ class AudioManager: NSObject, ObservableObject {
@unknown default:
break
}
self?.receiveMessage(for: source) // continue loop
// Continue loop for this session
if let self = self, self.sessionID == sessionID {
self.receiveMessage(for: source, sessionID: sessionID)
}
case .failure(let error):
guard let self = self, self.sessionID == sessionID else { return } // Stale callback
// Ignore errors caused by intentional socket closure after recording stops
if self.isRecording == false { return }
let errorMsg = self.handleWebSocketError(error, source: source)
print("❌ Receive error (\(source)): \(error)")
// Attempt reconnect if still recording
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
if self?.isRecording == true {
self?.connectToOpenAIRealtime(source: source)
DispatchQueue.main.async {
self.errorMessage = errorMsg
}
// Only attempt reconnect for network errors, not API errors
if ErrorHandler.shared.shouldRetry(error) {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
guard let self = self, self.isRecording, self.sessionID == sessionID else { return }
self.connectToOpenAIRealtime(source: source)
}
}
}
}
}
private func handleWebSocketError(_ error: Error, source: AudioSource) -> String {
// Check for WebSocket close codes first
if let closeCode = (error as NSError?)?.userInfo["closeCode"] as? Int {
return ErrorHandler.shared.handleWebSocketCloseCode(closeCode)
}
// Use centralized error handler for all other errors
return ErrorHandler.shared.handleError(error)
}
private func parseRealtimeEvent(_ text: String, source: AudioSource) {
guard let data = text.data(using: .utf8),
@@ -433,12 +500,19 @@ class AudioManager: NSObject, ObservableObject {
let base64 = data.base64EncodedString()
let message: [String: Any] = ["type": "input_audio_buffer.append", "audio": base64]
let thisSession = self.sessionID
do {
let jsonData = try JSONSerialization.data(withJSONObject: message)
if let jsonStr = String(data: jsonData, encoding: .utf8) {
socket.send(.string(jsonStr)) { error in
socket.send(.string(jsonStr)) { [weak self] error in
if let error = error {
guard let self = self, self.sessionID == thisSession else { return }
// Ignore cancellation errors, which are expected when stopping recording.
if (error as? URLError)?.code == .cancelled {
return
}
print("❌ Send error (\(source)): \(error)")
}
}
@@ -0,0 +1,89 @@
// APIKeyValidator.swift
// Service to validate OpenAI API keys and check for sufficient funds
import Foundation
/// Service to validate OpenAI API keys
class APIKeyValidator {
static let shared = APIKeyValidator()
private init() {}
/// Validates the OpenAI API key by making a test request
/// - Parameter apiKey: The API key to validate
/// - Returns: Result indicating success or failure with error message
func validateAPIKey(_ apiKey: String) async -> Result<Void, APIKeyValidationError> {
guard !apiKey.isEmpty else {
return .failure(.emptyKey)
}
// Make a simple request to validate the key
guard let url = URL(string: "https://api.openai.com/v1/models") else {
return .failure(.invalidURL)
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
return .failure(.networkError("Invalid response"))
}
switch httpResponse.statusCode {
case 200:
// Key is valid - check if models are available
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let models = json["data"] as? [[String: Any]], !models.isEmpty {
return .success(())
} else {
return .failure(.noModelsAvailable)
}
default:
let errorMessage = ErrorHandler.shared.handleHTTPStatusCode(httpResponse.statusCode)
return .failure(.httpError(errorMessage))
}
} catch {
let errorMessage = ErrorHandler.shared.handleError(error)
return .failure(.networkError(errorMessage))
}
}
/// Validates the currently stored API key
/// - Returns: Result indicating success or failure with error message
func validateCurrentAPIKey() async -> Result<Void, APIKeyValidationError> {
guard let apiKey = KeychainHelper.shared.getAPIKey() else {
return .failure(.emptyKey)
}
return await validateAPIKey(apiKey)
}
}
/// Errors that can occur during API key validation
enum APIKeyValidationError: Error, LocalizedError {
case emptyKey
case invalidURL
case noModelsAvailable
case networkError(String)
case httpError(String)
var errorDescription: String? {
switch self {
case .emptyKey:
return ErrorMessage.noAPIKey
case .invalidURL:
return ErrorMessage.invalidURL
case .noModelsAvailable:
return ErrorMessage.noModelsAvailable
case .networkError(let message):
return message
case .httpError(let message):
return message
}
}
}
+191
View File
@@ -0,0 +1,191 @@
// ErrorHandler.swift
// Centralized error handling service for OpenAI API and network errors
import Foundation
/// Centralized error handling service
class ErrorHandler {
static let shared = ErrorHandler()
private init() {}
/// Handles errors from OpenAI API calls and network requests
/// - Parameter error: The error to handle
/// - Returns: User-friendly error message
func handleError(_ error: Error) -> String {
// Handle network errors
if let urlError = error as? URLError {
return handleNetworkError(urlError)
}
// Handle HTTP response errors
if let httpError = error as? HTTPError {
return handleHTTPError(httpError)
}
// Handle OpenAI API errors by checking error description
let errorDescription = error.localizedDescription.lowercased()
if let openAIError = categorizeOpenAIError(errorDescription) {
return openAIError
}
// Generic error fallback
return "An unexpected error occurred: \(error.localizedDescription)"
}
/// Handles WebSocket close codes
/// - Parameter closeCode: WebSocket close code
/// - Returns: User-friendly error message
func handleWebSocketCloseCode(_ closeCode: Int) -> String {
switch closeCode {
case 1000: return "Connection closed normally"
case 1001: return ErrorMessage.connectionLost
case 1002: return "Connection protocol error. Please try again."
case 1003: return ErrorMessage.unsupportedData
case 1008: return "API policy violation. Please check your API key and account status."
case 1011: return ErrorMessage.apiServerError
case 4000: return ErrorMessage.badRequest
case 4001: return ErrorMessage.invalidAPIKey
case 4002: return ErrorMessage.accessForbidden
case 4003: return ErrorMessage.apiEndpointNotFound
case 4004: return "Invalid API method. Please update the app."
case 4005: return ErrorMessage.requestTimeout
case 4006: return ErrorMessage.requestTooLarge
case 4007: return ErrorMessage.rateLimited
case 4008: return ErrorMessage.insufficientFunds
default: return "Connection error (code \(closeCode)). Please try again."
}
}
/// Handles HTTP status codes
/// - Parameter statusCode: HTTP status code
/// - Parameter message: Optional error message
/// - Returns: User-friendly error message
func handleHTTPStatusCode(_ statusCode: Int, message: String? = nil) -> String {
switch statusCode {
case 200...299:
return ErrorMessage.success
case 400:
return ErrorMessage.badRequest
case 401:
return ErrorMessage.invalidAPIKey
case 402:
return ErrorMessage.insufficientFunds
case 403:
return ErrorMessage.accessForbidden
case 404:
return ErrorMessage.apiEndpointNotFound
case 429:
return ErrorMessage.rateLimited
case 500...599:
return ErrorMessage.apiServerError
default:
return "HTTP error \(statusCode): \(message ?? "Unknown error")"
}
}
/// Determines if an error should trigger a retry
/// - Parameter error: The error to check
/// - Returns: True if the error is retryable
func shouldRetry(_ error: Error) -> Bool {
// Network errors are generally retryable
if let urlError = error as? URLError {
switch urlError.code {
case .timedOut, .cannotFindHost, .networkConnectionLost, .cannotConnectToHost:
return true
default:
return false
}
}
// WebSocket close codes
if let closeCode = (error as NSError?)?.userInfo["closeCode"] as? Int {
return closeCode < 4000 // Only retry for non-API errors
}
return false
}
// MARK: - Private Methods
private func handleNetworkError(_ urlError: URLError) -> String {
switch urlError.code {
case .notConnectedToInternet:
return "No internet connection. Please check your network and try again."
case .timedOut:
return "Request timed out. Please try again."
case .cannotFindHost:
return "Cannot reach OpenAI servers. Please check your internet connection."
case .cannotConnectToHost:
return "Cannot connect to OpenAI servers. Please check your internet connection."
case .networkConnectionLost:
return "Network connection lost. Please try again."
case .httpTooManyRedirects:
return "Too many redirects. Please try again later."
case .secureConnectionFailed:
return "Secure connection failed. Please check your internet connection."
case .serverCertificateUntrusted:
return "Server certificate untrusted. Please try again."
default:
return "Network error: \(urlError.localizedDescription)"
}
}
private func handleHTTPError(_ httpError: HTTPError) -> String {
return handleHTTPStatusCode(httpError.statusCode, message: httpError.message)
}
private func categorizeOpenAIError(_ errorDescription: String) -> String? {
if errorDescription.contains("unauthorized") || errorDescription.contains("401") {
return ErrorMessage.invalidAPIKey
} else if errorDescription.contains("insufficient") || errorDescription.contains("402") {
return ErrorMessage.insufficientFunds
} else if errorDescription.contains("rate limit") || errorDescription.contains("429") {
return ErrorMessage.rateLimited
} else if errorDescription.contains("server error") || errorDescription.contains("500") {
return ErrorMessage.apiServerError
} else if errorDescription.contains("forbidden") || errorDescription.contains("403") {
return ErrorMessage.accessForbidden
} else if errorDescription.contains("not found") || errorDescription.contains("404") {
return ErrorMessage.apiEndpointNotFound
}
return nil
}
}
/// HTTP error type
struct HTTPError: Error {
let statusCode: Int
let message: String?
init(statusCode: Int, message: String? = nil) {
self.statusCode = statusCode
self.message = message
}
}
/// Common error messages
enum ErrorMessage {
static let noAPIKey = "OpenAI API key not found. Please configure your API key in Settings."
static let noTemplate = "No template content found. Please select a valid template."
static let noTranscript = "No transcript available. Please record some audio first."
static let connectionTimeout = "Failed to connect to OpenAI transcription service. Please check your internet connection and API key."
static let configurationFailed = "Failed to configure transcription session."
static let invalidURL = "Invalid API URL configuration."
static let noModelsAvailable = "No models available with your API key. Please check your account status."
// Centralized messages used across handlers
static let success = "Success"
static let badRequest = "Bad request. Please check your input."
static let invalidAPIKey = "Invalid OpenAI API key. Please check your API key in Settings."
static let insufficientFunds = "Insufficient funds in your OpenAI account. Please add credits to your account."
static let accessForbidden = "Access forbidden. Please check your API key permissions."
static let apiEndpointNotFound = "API endpoint not found. Please update the app."
static let rateLimited = "OpenAI API rate limit exceeded. Please try again later."
static let apiServerError = "OpenAI server error. Please try again later."
static let requestTimeout = "Request timeout. Please try again."
static let requestTooLarge = "Request too large. Please try again."
static let unsupportedData = "Unsupported data format. Please update the app."
static let connectionLost = "Connection lost. Please try again."
}
+31 -4
View File
@@ -4,6 +4,12 @@
import Foundation
import OpenAI
/// Result type for note generation streaming
enum GenerationResult {
case content(String)
case error(String)
}
/// Generates meeting notes using OpenAI API
class NotesGenerator {
static let shared = NotesGenerator()
@@ -20,16 +26,28 @@ class NotesGenerator {
func generateNotesStream(meeting: Meeting,
userBlurb: String,
systemPrompt: String,
templateId: UUID? = nil) -> AsyncStream<String> {
templateId: UUID? = nil) -> AsyncStream<GenerationResult> {
return AsyncStream<String>(String.self) { continuation in
return AsyncStream<GenerationResult>(GenerationResult.self) { continuation in
Task {
do {
guard let apiKey = KeychainHelper.shared.getAPIKey(), !apiKey.isEmpty else {
continuation.yield(.error(ErrorMessage.noAPIKey))
continuation.finish()
return
}
// Validate API key before proceeding
let validationResult = await APIKeyValidator.shared.validateAPIKey(apiKey)
switch validationResult {
case .failure(let error):
continuation.yield(.error(error.localizedDescription))
continuation.finish()
return
case .success():
break
}
let openAI = OpenAI(apiToken: apiKey)
// Create date formatter for meeting date
@@ -48,7 +66,14 @@ class NotesGenerator {
// If no template content, use default
if templateContent.isEmpty {
// No template content found, finish the stream
continuation.yield(.error(ErrorMessage.noTemplate))
continuation.finish()
return
}
// Check if transcript is empty
if meeting.formattedTranscript.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
continuation.yield(.error(ErrorMessage.noTranscript))
continuation.finish()
return
}
@@ -75,13 +100,15 @@ class NotesGenerator {
for try await result in stream {
if let content = result.choices.first?.delta.content {
continuation.yield(content)
continuation.yield(.content(content))
}
}
continuation.finish()
} catch {
let errorMessage = ErrorHandler.shared.handleError(error)
print("Error in streaming generation: \(error)")
continuation.yield(.error(errorMessage))
continuation.finish()
}
}
+41 -4
View File
@@ -98,6 +98,15 @@ class MeetingViewModel: ObservableObject {
}
.store(in: &cancellables)
// Update error message when audio manager encounters errors
audioManager.$errorMessage
.compactMap { $0 }
.sink { [weak self] errorMessage in
self?.errorMessage = errorMessage
print("🚨 Audio Manager Error: \(errorMessage)")
}
.store(in: &cancellables)
// Auto-save when meeting properties change
$meeting
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
@@ -143,7 +152,20 @@ class MeetingViewModel: ObservableObject {
}
func startRecording() {
audioManager.startRecording()
// Validate API key before starting recording
Task {
let validationResult = await APIKeyValidator.shared.validateCurrentAPIKey()
switch validationResult {
case .success():
// Key is valid, proceed with recording
audioManager.startRecording()
case .failure(let error):
// Show error message
errorMessage = error.localizedDescription
print("❌ API key validation failed: \(error.localizedDescription)")
}
}
}
func stopRecording() {
@@ -176,6 +198,9 @@ class MeetingViewModel: ObservableObject {
isGeneratingNotes = true
errorMessage = nil
// Clear any audio manager errors as well
audioManager.errorMessage = nil
// Clear existing notes for streaming
meeting.generatedNotes = ""
@@ -191,11 +216,23 @@ class MeetingViewModel: ObservableObject {
templateId: selectedTemplateId
)
for await chunk in stream {
meeting.generatedNotes += chunk
var hasError = false
for await result in stream {
switch result {
case .content(let chunk):
meeting.generatedNotes += chunk
case .error(let error):
errorMessage = error
hasError = true
print("🚨 Note Generation Error: \(error)")
break
}
}
saveMeeting()
// Only save if there was no error
if !hasError {
saveMeeting()
}
isGeneratingNotes = false
}