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.
This commit is contained in:
Beingpax 2026-01-05 22:28:34 +05:45
parent 6a15814bf9
commit 948033ac28
13 changed files with 415 additions and 46 deletions

View File

@ -91,7 +91,7 @@ struct CloudModel: TranscriptionModel {
} }
} }
// A new struct for custom cloud models /// Custom cloud model with API key stored in Keychain.
struct CustomCloudModel: TranscriptionModel, Codable { struct CustomCloudModel: TranscriptionModel, Codable {
let id: UUID let id: UUID
let name: String let name: String
@ -99,22 +99,59 @@ struct CustomCloudModel: TranscriptionModel, Codable {
let description: String let description: String
let provider: ModelProvider = .custom let provider: ModelProvider = .custom
let apiEndpoint: String let apiEndpoint: String
let apiKey: String
let modelName: String let modelName: String
let isMultilingualModel: Bool let isMultilingualModel: Bool
let supportedLanguages: [String: String] let supportedLanguages: [String: String]
init(id: UUID = UUID(), name: String, displayName: String, description: String, apiEndpoint: String, apiKey: String, modelName: String, isMultilingual: Bool = true, supportedLanguages: [String: String]? = nil) { /// API key retrieved from Keychain by model ID.
var apiKey: String {
APIKeyManager.shared.getCustomModelAPIKey(forModelId: id) ?? ""
}
init(id: UUID = UUID(), name: String, displayName: String, description: String, apiEndpoint: String, modelName: String, isMultilingual: Bool = true, supportedLanguages: [String: String]? = nil) {
self.id = id self.id = id
self.name = name self.name = name
self.displayName = displayName self.displayName = displayName
self.description = description self.description = description
self.apiEndpoint = apiEndpoint self.apiEndpoint = apiEndpoint
self.apiKey = apiKey
self.modelName = modelName self.modelName = modelName
self.isMultilingualModel = isMultilingual self.isMultilingualModel = isMultilingual
self.supportedLanguages = supportedLanguages ?? PredefinedModels.getLanguageDictionary(isMultilingual: isMultilingual) self.supportedLanguages = supportedLanguages ?? PredefinedModels.getLanguageDictionary(isMultilingual: isMultilingual)
} }
/// Custom Codable to migrate legacy apiKey from JSON to Keychain.
private enum CodingKeys: String, CodingKey {
case id, name, displayName, description, apiEndpoint, modelName, isMultilingualModel, supportedLanguages
case apiKey
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
displayName = try container.decode(String.self, forKey: .displayName)
description = try container.decode(String.self, forKey: .description)
apiEndpoint = try container.decode(String.self, forKey: .apiEndpoint)
modelName = try container.decode(String.self, forKey: .modelName)
isMultilingualModel = try container.decode(Bool.self, forKey: .isMultilingualModel)
supportedLanguages = try container.decode([String: String].self, forKey: .supportedLanguages)
if let legacyApiKey = try container.decodeIfPresent(String.self, forKey: .apiKey), !legacyApiKey.isEmpty {
APIKeyManager.shared.saveCustomModelAPIKey(legacyApiKey, forModelId: id)
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(name, forKey: .name)
try container.encode(displayName, forKey: .displayName)
try container.encode(description, forKey: .description)
try container.encode(apiEndpoint, forKey: .apiEndpoint)
try container.encode(modelName, forKey: .modelName)
try container.encode(isMultilingualModel, forKey: .isMultilingualModel)
try container.encode(supportedLanguages, forKey: .supportedLanguages)
}
} }
struct LocalModel: TranscriptionModel { struct LocalModel: TranscriptionModel {

View File

@ -167,7 +167,7 @@ class AIService: ObservableObject {
didSet { didSet {
userDefaults.set(selectedProvider.rawValue, forKey: "selectedAIProvider") userDefaults.set(selectedProvider.rawValue, forKey: "selectedAIProvider")
if selectedProvider.requiresAPIKey { if selectedProvider.requiresAPIKey {
if let savedKey = userDefaults.string(forKey: "\(selectedProvider.rawValue)APIKey") { if let savedKey = APIKeyManager.shared.getAPIKey(forProvider: selectedProvider.rawValue) {
self.apiKey = savedKey self.apiKey = savedKey
self.isAPIKeyValid = true self.isAPIKeyValid = true
} else { } else {
@ -199,7 +199,7 @@ class AIService: ObservableObject {
if provider == .ollama { if provider == .ollama {
return ollamaService.isConnected return ollamaService.isConnected
} else if provider.requiresAPIKey { } else if provider.requiresAPIKey {
return userDefaults.string(forKey: "\(provider.rawValue)APIKey") != nil return APIKeyManager.shared.hasAPIKey(forProvider: provider.rawValue)
} }
return false return false
} }
@ -230,16 +230,16 @@ class AIService: ObservableObject {
} else { } else {
self.selectedProvider = .gemini self.selectedProvider = .gemini
} }
if selectedProvider.requiresAPIKey { if selectedProvider.requiresAPIKey {
if let savedKey = userDefaults.string(forKey: "\(selectedProvider.rawValue)APIKey") { if let savedKey = APIKeyManager.shared.getAPIKey(forProvider: selectedProvider.rawValue) {
self.apiKey = savedKey self.apiKey = savedKey
self.isAPIKeyValid = true self.isAPIKeyValid = true
} }
} else { } else {
self.isAPIKeyValid = true self.isAPIKeyValid = true
} }
loadSavedModelSelections() loadSavedModelSelections()
loadSavedOpenRouterModels() loadSavedOpenRouterModels()
} }
@ -283,14 +283,14 @@ class AIService: ObservableObject {
completion(true, nil) completion(true, nil)
return return
} }
verifyAPIKey(key) { [weak self] isValid, errorMessage in verifyAPIKey(key) { [weak self] isValid, errorMessage in
guard let self = self else { return } guard let self = self else { return }
DispatchQueue.main.async { DispatchQueue.main.async {
if isValid { if isValid {
self.apiKey = key self.apiKey = key
self.isAPIKeyValid = true self.isAPIKeyValid = true
self.userDefaults.set(key, forKey: "\(self.selectedProvider.rawValue)APIKey") APIKeyManager.shared.saveAPIKey(key, forProvider: self.selectedProvider.rawValue)
NotificationCenter.default.post(name: .aiProviderKeyChanged, object: nil) NotificationCenter.default.post(name: .aiProviderKeyChanged, object: nil)
} else { } else {
self.isAPIKeyValid = false self.isAPIKeyValid = false
@ -515,10 +515,10 @@ class AIService: ObservableObject {
func clearAPIKey() { func clearAPIKey() {
guard selectedProvider.requiresAPIKey else { return } guard selectedProvider.requiresAPIKey else { return }
apiKey = "" apiKey = ""
isAPIKeyValid = false isAPIKeyValid = false
userDefaults.removeObject(forKey: "\(selectedProvider.rawValue)APIKey") APIKeyManager.shared.deleteAPIKey(forProvider: selectedProvider.rawValue)
NotificationCenter.default.post(name: .aiProviderKeyChanged, object: nil) NotificationCenter.default.post(name: .aiProviderKeyChanged, object: nil)
} }

View File

@ -0,0 +1,225 @@
import Foundation
import os
/// Manages API keys using secure Keychain storage with automatic migration from UserDefaults.
final class APIKeyManager {
static let shared = APIKeyManager()
private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "APIKeyManager")
private let keychain = KeychainService.shared
private let userDefaults = UserDefaults.standard
private let migrationCompletedKey = "APIKeyMigrationToKeychainCompleted_v2"
/// Provider to Keychain identifier mapping (iOS compatible for iCloud sync).
private static let providerToKeychainKey: [String: String] = [
"groq": "groqAPIKey",
"deepgram": "deepgramAPIKey",
"cerebras": "cerebrasAPIKey",
"gemini": "geminiAPIKey",
"mistral": "mistralAPIKey",
"elevenlabs": "elevenLabsAPIKey",
"soniox": "sonioxAPIKey",
"openai": "openAIAPIKey",
"anthropic": "anthropicAPIKey",
"openrouter": "openRouterAPIKey"
]
/// Legacy UserDefaults to Keychain key mapping for migration.
private static let userDefaultsToKeychainMapping: [String: String] = [
"GROQAPIKey": "groqAPIKey",
"DeepgramAPIKey": "deepgramAPIKey",
"CerebrasAPIKey": "cerebrasAPIKey",
"GeminiAPIKey": "geminiAPIKey",
"MistralAPIKey": "mistralAPIKey",
"ElevenLabsAPIKey": "elevenLabsAPIKey",
"SonioxAPIKey": "sonioxAPIKey",
"OpenAIAPIKey": "openAIAPIKey",
"AnthropicAPIKey": "anthropicAPIKey",
"OpenRouterAPIKey": "openRouterAPIKey"
]
private init() {
migrateFromUserDefaultsIfNeeded()
}
// MARK: - Standard Provider API Keys
/// Saves an API key for a provider.
@discardableResult
func saveAPIKey(_ key: String, forProvider provider: String) -> Bool {
let keyIdentifier = keychainIdentifier(forProvider: provider)
let success = keychain.save(key, forKey: keyIdentifier)
if success {
logger.info("Saved API key for provider: \(provider) with key: \(keyIdentifier)")
// Clean up any remaining UserDefaults entries (both old and new format)
cleanupUserDefaultsForProvider(provider)
}
return success
}
/// Retrieves an API key for a provider.
func getAPIKey(forProvider provider: String) -> String? {
let keyIdentifier = keychainIdentifier(forProvider: provider)
// First try Keychain with new identifier
if let key = keychain.getString(forKey: keyIdentifier), !key.isEmpty {
return key
}
let oldKey = oldUserDefaultsKey(forProvider: provider)
if let key = userDefaults.string(forKey: oldKey), !key.isEmpty {
logger.info("Migrating \(oldKey) to Keychain")
keychain.save(key, forKey: keyIdentifier)
userDefaults.removeObject(forKey: oldKey)
return key
}
return nil
}
/// Deletes an API key for a provider.
@discardableResult
func deleteAPIKey(forProvider provider: String) -> Bool {
let keyIdentifier = keychainIdentifier(forProvider: provider)
let success = keychain.delete(forKey: keyIdentifier)
cleanupUserDefaultsForProvider(provider)
if success {
logger.info("Deleted API key for provider: \(provider)")
}
return success
}
/// Checks if an API key exists for a provider.
func hasAPIKey(forProvider provider: String) -> Bool {
return getAPIKey(forProvider: provider) != nil
}
// MARK: - Custom Model API Keys
/// Saves an API key for a custom model.
@discardableResult
func saveCustomModelAPIKey(_ key: String, forModelId modelId: UUID) -> Bool {
let keyIdentifier = customModelKeyIdentifier(for: modelId)
let success = keychain.save(key, forKey: keyIdentifier)
if success {
logger.info("Saved API key for custom model: \(modelId.uuidString)")
}
return success
}
/// Retrieves an API key for a custom model.
func getCustomModelAPIKey(forModelId modelId: UUID) -> String? {
let keyIdentifier = customModelKeyIdentifier(for: modelId)
return keychain.getString(forKey: keyIdentifier)
}
/// Deletes an API key for a custom model.
@discardableResult
func deleteCustomModelAPIKey(forModelId modelId: UUID) -> Bool {
let keyIdentifier = customModelKeyIdentifier(for: modelId)
let success = keychain.delete(forKey: keyIdentifier)
if success {
logger.info("Deleted API key for custom model: \(modelId.uuidString)")
}
return success
}
// MARK: - Migration
/// Migrates API keys from UserDefaults to Keychain on first run.
private func migrateFromUserDefaultsIfNeeded() {
if userDefaults.bool(forKey: migrationCompletedKey) {
return
}
logger.info("Starting API key migration")
var migratedCount = 0
for (oldKey, newKey) in Self.userDefaultsToKeychainMapping {
if let value = userDefaults.string(forKey: oldKey), !value.isEmpty {
if keychain.save(value, forKey: newKey) {
userDefaults.removeObject(forKey: oldKey)
migratedCount += 1
} else {
logger.error("Failed to migrate \(oldKey)")
}
}
}
migrateCustomModelAPIKeys()
userDefaults.set(true, forKey: migrationCompletedKey)
logger.info("Migration completed. Migrated \(migratedCount) API keys.")
}
/// Migrates custom model API keys from UserDefaults.
private func migrateCustomModelAPIKeys() {
guard let data = userDefaults.data(forKey: "customCloudModels") else {
return
}
struct LegacyCustomCloudModel: Codable {
let id: UUID
let apiKey: String
}
do {
let legacyModels = try JSONDecoder().decode([LegacyCustomCloudModel].self, from: data)
for model in legacyModels where !model.apiKey.isEmpty {
let keyIdentifier = customModelKeyIdentifier(for: model.id)
keychain.save(model.apiKey, forKey: keyIdentifier)
}
} catch {
logger.error("Failed to decode legacy custom models: \(error.localizedDescription)")
}
}
// MARK: - Key Identifier Helpers
/// Returns Keychain identifier for a provider (case-insensitive).
private func keychainIdentifier(forProvider provider: String) -> String {
let lowercased = provider.lowercased()
if let mapped = Self.providerToKeychainKey[lowercased] {
return mapped
}
return "\(lowercased)APIKey"
}
/// Returns old UserDefaults key for provider (pre-Keychain format).
private func oldUserDefaultsKey(forProvider provider: String) -> String {
switch provider.lowercased() {
case "groq":
return "GROQAPIKey"
case "deepgram":
return "DeepgramAPIKey"
case "cerebras":
return "CerebrasAPIKey"
case "gemini":
return "GeminiAPIKey"
case "mistral":
return "MistralAPIKey"
case "elevenlabs":
return "ElevenLabsAPIKey"
case "soniox":
return "SonioxAPIKey"
case "openai":
return "OpenAIAPIKey"
case "anthropic":
return "AnthropicAPIKey"
case "openrouter":
return "OpenRouterAPIKey"
default:
return "\(provider)APIKey"
}
}
/// Cleans up UserDefaults entries for a provider.
private func cleanupUserDefaultsForProvider(_ provider: String) {
userDefaults.removeObject(forKey: oldUserDefaultsKey(forProvider: provider))
}
/// Generates Keychain identifier for custom model API key.
private func customModelKeyIdentifier(for modelId: UUID) -> String {
"customModel_\(modelId.uuidString)_APIKey"
}
}

View File

@ -25,6 +25,7 @@ class CustomModelManager: ObservableObject {
func removeCustomModel(withId id: UUID) { func removeCustomModel(withId id: UUID) {
customModels.removeAll { $0.id == id } customModels.removeAll { $0.id == id }
saveCustomModels() saveCustomModels()
APIKeyManager.shared.deleteCustomModelAPIKey(forModelId: id)
logger.info("Removed custom model with ID: \(id)") logger.info("Removed custom model with ID: \(id)")
} }

View File

@ -42,7 +42,7 @@ class DeepgramTranscriptionService {
} }
private func getAPIConfig(for model: any TranscriptionModel) throws -> APIConfig { private func getAPIConfig(for model: any TranscriptionModel) throws -> APIConfig {
guard let apiKey = UserDefaults.standard.string(forKey: "DeepgramAPIKey"), !apiKey.isEmpty else { guard let apiKey = APIKeyManager.shared.getAPIKey(forProvider: "Deepgram"), !apiKey.isEmpty else {
throw CloudTranscriptionError.missingAPIKey throw CloudTranscriptionError.missingAPIKey
} }

View File

@ -6,7 +6,7 @@ class ElevenLabsTranscriptionService {
private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "ElevenLabsTranscriptionService") private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "ElevenLabsTranscriptionService")
func transcribe(audioURL: URL, model: any TranscriptionModel) async throws -> String { func transcribe(audioURL: URL, model: any TranscriptionModel) async throws -> String {
guard let apiKey = UserDefaults.standard.string(forKey: "ElevenLabsAPIKey"), !apiKey.isEmpty else { guard let apiKey = APIKeyManager.shared.getAPIKey(forProvider: "ElevenLabs"), !apiKey.isEmpty else {
throw CloudTranscriptionError.missingAPIKey throw CloudTranscriptionError.missingAPIKey
} }

View File

@ -75,15 +75,15 @@ class GeminiTranscriptionService {
} }
private func getAPIConfig(for model: any TranscriptionModel) throws -> APIConfig { private func getAPIConfig(for model: any TranscriptionModel) throws -> APIConfig {
guard let apiKey = UserDefaults.standard.string(forKey: "GeminiAPIKey"), !apiKey.isEmpty else { guard let apiKey = APIKeyManager.shared.getAPIKey(forProvider: "Gemini"), !apiKey.isEmpty else {
throw CloudTranscriptionError.missingAPIKey throw CloudTranscriptionError.missingAPIKey
} }
let urlString = "https://generativelanguage.googleapis.com/v1beta/models/\(model.name):generateContent" let urlString = "https://generativelanguage.googleapis.com/v1beta/models/\(model.name):generateContent"
guard let apiURL = URL(string: urlString) else { guard let apiURL = URL(string: urlString) else {
throw CloudTranscriptionError.dataEncodingError throw CloudTranscriptionError.dataEncodingError
} }
return APIConfig(url: apiURL, apiKey: apiKey, modelName: model.name) return APIConfig(url: apiURL, apiKey: apiKey, modelName: model.name)
} }

View File

@ -102,10 +102,10 @@ class GroqTranscriptionService {
} }
private func getAPIConfig(for model: any TranscriptionModel) throws -> APIConfig { private func getAPIConfig(for model: any TranscriptionModel) throws -> APIConfig {
guard let apiKey = UserDefaults.standard.string(forKey: "GROQAPIKey"), !apiKey.isEmpty else { guard let apiKey = APIKeyManager.shared.getAPIKey(forProvider: "Groq"), !apiKey.isEmpty else {
throw CloudTranscriptionError.missingAPIKey throw CloudTranscriptionError.missingAPIKey
} }
guard let apiURL = URL(string: "https://api.groq.com/openai/v1/audio/transcriptions") else { 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"]) throw NSError(domain: "GroqTranscriptionService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid API URL"])
} }

View File

@ -6,8 +6,7 @@ class MistralTranscriptionService {
func transcribe(audioURL: URL, model: any TranscriptionModel) async throws -> String { func transcribe(audioURL: URL, model: any TranscriptionModel) async throws -> String {
logger.notice("Sending transcription request to Mistral for model: \(model.name)") logger.notice("Sending transcription request to Mistral for model: \(model.name)")
let apiKey = UserDefaults.standard.string(forKey: "MistralAPIKey") ?? "" guard let apiKey = APIKeyManager.shared.getAPIKey(forProvider: "Mistral"), !apiKey.isEmpty else {
guard !apiKey.isEmpty else {
logger.error("Mistral API key is missing.") logger.error("Mistral API key is missing.")
throw CloudTranscriptionError.missingAPIKey throw CloudTranscriptionError.missingAPIKey
} }

View File

@ -24,7 +24,7 @@ class SonioxTranscriptionService {
} }
private func getAPIConfig(for model: any TranscriptionModel) throws -> APIConfig { private func getAPIConfig(for model: any TranscriptionModel) throws -> APIConfig {
guard let apiKey = UserDefaults.standard.string(forKey: "SonioxAPIKey"), !apiKey.isEmpty else { guard let apiKey = APIKeyManager.shared.getAPIKey(forProvider: "Soniox"), !apiKey.isEmpty else {
throw CloudTranscriptionError.missingAPIKey throw CloudTranscriptionError.missingAPIKey
} }
return APIConfig(apiKey: apiKey) return APIConfig(apiKey: apiKey)

View File

@ -0,0 +1,117 @@
import Foundation
import Security
import os
/// Securely stores and retrieves API keys using Keychain with iCloud sync.
final class KeychainService {
static let shared = KeychainService()
private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "KeychainService")
private let accessGroup = "com.prakashjoshipax.VoiceInk"
private let service = "com.prakashjoshipax.VoiceInk"
private init() {}
// MARK: - Public API
/// Saves a string value to Keychain.
@discardableResult
func save(_ value: String, forKey key: String, syncable: Bool = true) -> Bool {
guard let data = value.data(using: .utf8) else {
logger.error("Failed to convert value to data for key: \(key)")
return false
}
return save(data: data, forKey: key, syncable: syncable)
}
/// Saves data to Keychain.
@discardableResult
func save(data: Data, forKey key: String, syncable: Bool = true) -> Bool {
// First, try to delete any existing item to avoid duplicates
delete(forKey: key, syncable: syncable)
var query = baseQuery(forKey: key, syncable: syncable)
query[kSecValueData as String] = data
let status = SecItemAdd(query as CFDictionary, nil)
if status == errSecSuccess {
logger.info("Successfully saved keychain item for key: \(key)")
return true
} else {
logger.error("Failed to save keychain item for key: \(key), status: \(status)")
return false
}
}
/// Retrieves a string value from Keychain.
func getString(forKey key: String, syncable: Bool = true) -> String? {
guard let data = getData(forKey: key, syncable: syncable) else {
return nil
}
return String(data: data, encoding: .utf8)
}
/// Retrieves data from Keychain.
func getData(forKey key: String, syncable: Bool = true) -> Data? {
var query = baseQuery(forKey: key, syncable: syncable)
query[kSecReturnData as String] = kCFBooleanTrue
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess {
return result as? Data
} else if status != errSecItemNotFound {
logger.error("Failed to retrieve keychain item for key: \(key), status: \(status)")
}
return nil
}
/// Deletes an item from Keychain.
@discardableResult
func delete(forKey key: String, syncable: Bool = true) -> Bool {
let query = baseQuery(forKey: key, syncable: syncable)
let status = SecItemDelete(query as CFDictionary)
if status == errSecSuccess || status == errSecItemNotFound {
if status == errSecSuccess {
logger.info("Successfully deleted keychain item for key: \(key)")
}
return true
} else {
logger.error("Failed to delete keychain item for key: \(key), status: \(status)")
return false
}
}
/// Checks if a key exists in Keychain.
func exists(forKey key: String, syncable: Bool = true) -> Bool {
var query = baseQuery(forKey: key, syncable: syncable)
query[kSecReturnData as String] = kCFBooleanFalse
let status = SecItemCopyMatching(query as CFDictionary, nil)
return status == errSecSuccess
}
// MARK: - Private Helpers
/// Creates base Keychain query dictionary.
private func baseQuery(forKey key: String, syncable: Bool) -> [String: Any] {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecAttrAccessGroup as String: accessGroup,
kSecUseDataProtectionKeychain as String: true
]
if syncable {
query[kSecAttrSynchronizable as String] = kCFBooleanTrue
}
return query
}
}

View File

@ -223,39 +223,35 @@ struct AddCustomModelCardView: View {
} }
isSaving = true isSaving = true
// Simulate a brief save operation for better UX
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
if let editing = editingModel { if let editing = editingModel {
// Update existing model
let updatedModel = CustomCloudModel( let updatedModel = CustomCloudModel(
id: editing.id, id: editing.id,
name: generatedName, name: generatedName,
displayName: trimmedDisplayName, displayName: trimmedDisplayName,
description: "Custom transcription model", description: "Custom transcription model",
apiEndpoint: trimmedApiEndpoint, apiEndpoint: trimmedApiEndpoint,
apiKey: trimmedApiKey,
modelName: trimmedModelName, modelName: trimmedModelName,
isMultilingual: isMultilingual isMultilingual: isMultilingual
) )
APIKeyManager.shared.saveCustomModelAPIKey(trimmedApiKey, forModelId: editing.id)
customModelManager.updateCustomModel(updatedModel) customModelManager.updateCustomModel(updatedModel)
} else { } else {
// Add new model
let customModel = CustomCloudModel( let customModel = CustomCloudModel(
name: generatedName, name: generatedName,
displayName: trimmedDisplayName, displayName: trimmedDisplayName,
description: "Custom transcription model", description: "Custom transcription model",
apiEndpoint: trimmedApiEndpoint, apiEndpoint: trimmedApiEndpoint,
apiKey: trimmedApiKey,
modelName: trimmedModelName, modelName: trimmedModelName,
isMultilingual: isMultilingual isMultilingual: isMultilingual
) )
APIKeyManager.shared.saveCustomModelAPIKey(trimmedApiKey, forModelId: customModel.id)
customModelManager.addCustomModel(customModel) customModelManager.addCustomModel(customModel)
} }
onModelAdded() onModelAdded()
// Reset form and collapse
withAnimation(.interpolatingSpring(stiffness: 170, damping: 20)) { withAnimation(.interpolatingSpring(stiffness: 170, damping: 20)) {
isExpanded = false isExpanded = false
clearForm() clearForm()

View File

@ -21,10 +21,7 @@ struct CloudModelCardView: View {
} }
private var isConfigured: Bool { private var isConfigured: Bool {
guard let savedKey = UserDefaults.standard.string(forKey: "\(providerKey)APIKey") else { return APIKeyManager.shared.hasAPIKey(forProvider: providerKey)
return false
}
return !savedKey.isEmpty
} }
private var providerKey: String { private var providerKey: String {
@ -267,7 +264,7 @@ struct CloudModelCardView: View {
} }
private func loadSavedAPIKey() { private func loadSavedAPIKey() {
if let savedKey = UserDefaults.standard.string(forKey: "\(providerKey)APIKey") { if let savedKey = APIKeyManager.shared.getAPIKey(forProvider: providerKey) {
apiKey = savedKey apiKey = savedKey
verificationStatus = .success verificationStatus = .success
} }
@ -306,10 +303,10 @@ struct CloudModelCardView: View {
if isValid { if isValid {
self.verificationStatus = .success self.verificationStatus = .success
self.verificationError = nil self.verificationError = nil
// Save the API key // Save the API key to Keychain
UserDefaults.standard.set(self.apiKey, forKey: "\(self.providerKey)APIKey") APIKeyManager.shared.saveAPIKey(self.apiKey, forProvider: self.providerKey)
self.isConfiguredState = true self.isConfiguredState = true
// Collapse the configuration section after successful verification // Collapse the configuration section after successful verification
withAnimation(.easeInOut(duration: 0.3)) { withAnimation(.easeInOut(duration: 0.3)) {
self.isExpanded = false self.isExpanded = false
@ -318,20 +315,17 @@ struct CloudModelCardView: View {
self.verificationStatus = .failure self.verificationStatus = .failure
self.verificationError = errorMessage self.verificationError = errorMessage
} }
// Restore original provider
// aiService.selectedProvider = originalProvider // This line was removed as per the new_code
} }
} }
} }
private func clearAPIKey() { private func clearAPIKey() {
UserDefaults.standard.removeObject(forKey: "\(providerKey)APIKey") APIKeyManager.shared.deleteAPIKey(forProvider: providerKey)
apiKey = "" apiKey = ""
verificationStatus = .none verificationStatus = .none
verificationError = nil verificationError = nil
isConfiguredState = false isConfiguredState = false
// If this model is currently the default, clear it // If this model is currently the default, clear it
if isCurrent { if isCurrent {
Task { Task {
@ -341,7 +335,7 @@ struct CloudModelCardView: View {
} }
} }
} }
withAnimation(.easeInOut(duration: 0.3)) { withAnimation(.easeInOut(duration: 0.3)) {
isExpanded = false isExpanded = false
} }