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

609 lines
21 KiB
Swift

import Foundation
enum AIProvider: String, CaseIterable {
case cerebras = "Cerebras"
case groq = "GROQ"
case gemini = "Gemini"
case anthropic = "Anthropic"
case openAI = "OpenAI"
case openRouter = "OpenRouter"
case mistral = "Mistral"
case elevenLabs = "ElevenLabs"
case deepgram = "Deepgram"
case soniox = "Soniox"
case ollama = "Ollama"
case custom = "Custom"
var baseURL: String {
switch self {
case .cerebras:
return "https://api.cerebras.ai/v1/chat/completions"
case .groq:
return "https://api.groq.com/openai/v1/chat/completions"
case .gemini:
return "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions"
case .anthropic:
return "https://api.anthropic.com/v1/messages"
case .openAI:
return "https://api.openai.com/v1/chat/completions"
case .openRouter:
return "https://openrouter.ai/api/v1/chat/completions"
case .mistral:
return "https://api.mistral.ai/v1/chat/completions"
case .elevenLabs:
return "https://api.elevenlabs.io/v1/speech-to-text"
case .deepgram:
return "https://api.deepgram.com/v1/listen"
case .soniox:
return "https://api.soniox.com/v1"
case .ollama:
return UserDefaults.standard.string(forKey: "ollamaBaseURL") ?? "http://localhost:11434"
case .custom:
return UserDefaults.standard.string(forKey: "customProviderBaseURL") ?? ""
}
}
var defaultModel: String {
switch self {
case .cerebras:
return "gpt-oss-120b"
case .groq:
return "openai/gpt-oss-120b"
case .gemini:
return "gemini-2.5-flash-lite"
case .anthropic:
return "claude-sonnet-4-5"
case .openAI:
return "gpt-5.2"
case .mistral:
return "mistral-large-latest"
case .elevenLabs:
return "scribe_v1"
case .deepgram:
return "whisper-1"
case .soniox:
return "stt-async-v3"
case .ollama:
return UserDefaults.standard.string(forKey: "ollamaSelectedModel") ?? "mistral"
case .custom:
return UserDefaults.standard.string(forKey: "customProviderModel") ?? ""
case .openRouter:
return "openai/gpt-oss-120b"
}
}
var availableModels: [String] {
switch self {
case .cerebras:
return [
"gpt-oss-120b",
"llama-3.1-8b",
"llama-4-scout-17b-16e-instruct",
"llama-3.3-70b",
"qwen-3-32b",
"qwen-3-235b-a22b-instruct-2507"
]
case .groq:
return [
"llama-3.1-8b-instant",
"llama-3.3-70b-versatile",
"moonshotai/kimi-k2-instruct-0905",
"qwen/qwen3-32b",
"meta-llama/llama-4-maverick-17b-128e-instruct",
"openai/gpt-oss-120b",
"openai/gpt-oss-20b"
]
case .gemini:
return [
"gemini-3-flash-preview",
"gemini-3-pro-preview",
"gemini-2.5-pro",
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
"gemini-2.0-flash-001"
]
case .anthropic:
return [
"claude-opus-4-5",
"claude-sonnet-4-5",
"claude-haiku-4-5"
]
case .openAI:
return [
"gpt-5.2",
"gpt-5.1",
"gpt-5-mini",
"gpt-5-nano",
"gpt-4.1",
"gpt-4.1-mini"
]
case .mistral:
return [
"mistral-large-latest",
"mistral-medium-latest",
"mistral-small-latest",
"mistral-saba-latest"
]
case .elevenLabs:
return ["scribe_v1", "scribe_v1_experimental"]
case .deepgram:
return ["whisper-1"]
case .soniox:
return ["stt-async-v3"]
case .ollama:
return []
case .custom:
return []
case .openRouter:
return []
}
}
var requiresAPIKey: Bool {
switch self {
case .ollama:
return false
default:
return true
}
}
}
class AIService: ObservableObject {
@Published var apiKey: String = ""
@Published var isAPIKeyValid: Bool = false
@Published var customBaseURL: String = UserDefaults.standard.string(forKey: "customProviderBaseURL") ?? "" {
didSet {
userDefaults.set(customBaseURL, forKey: "customProviderBaseURL")
}
}
@Published var customModel: String = UserDefaults.standard.string(forKey: "customProviderModel") ?? "" {
didSet {
userDefaults.set(customModel, forKey: "customProviderModel")
}
}
@Published var selectedProvider: AIProvider {
didSet {
userDefaults.set(selectedProvider.rawValue, forKey: "selectedAIProvider")
if selectedProvider.requiresAPIKey {
if let savedKey = APIKeyManager.shared.getAPIKey(forProvider: selectedProvider.rawValue) {
self.apiKey = savedKey
self.isAPIKeyValid = true
} else {
self.apiKey = ""
self.isAPIKeyValid = false
}
} else {
self.apiKey = ""
self.isAPIKeyValid = true
if selectedProvider == .ollama {
Task {
await ollamaService.checkConnection()
await ollamaService.refreshModels()
}
}
}
NotificationCenter.default.post(name: .AppSettingsDidChange, object: nil)
}
}
@Published private var selectedModels: [AIProvider: String] = [:]
private let userDefaults = UserDefaults.standard
private lazy var ollamaService = OllamaService()
@Published private var openRouterModels: [String] = []
var connectedProviders: [AIProvider] {
AIProvider.allCases.filter { provider in
if provider == .ollama {
return ollamaService.isConnected
} else if provider.requiresAPIKey {
return APIKeyManager.shared.hasAPIKey(forProvider: provider.rawValue)
}
return false
}
}
var currentModel: String {
if let selectedModel = selectedModels[selectedProvider],
!selectedModel.isEmpty,
(selectedProvider == .ollama && !selectedModel.isEmpty) || availableModels.contains(selectedModel) {
return selectedModel
}
return selectedProvider.defaultModel
}
var availableModels: [String] {
if selectedProvider == .ollama {
return ollamaService.availableModels.map { $0.name }
} else if selectedProvider == .openRouter {
return openRouterModels
}
return selectedProvider.availableModels
}
init() {
if let savedProvider = userDefaults.string(forKey: "selectedAIProvider"),
let provider = AIProvider(rawValue: savedProvider) {
self.selectedProvider = provider
} else {
self.selectedProvider = .gemini
}
if selectedProvider.requiresAPIKey {
if let savedKey = APIKeyManager.shared.getAPIKey(forProvider: selectedProvider.rawValue) {
self.apiKey = savedKey
self.isAPIKeyValid = true
}
} else {
self.isAPIKeyValid = true
}
loadSavedModelSelections()
loadSavedOpenRouterModels()
}
private func loadSavedModelSelections() {
for provider in AIProvider.allCases {
let key = "\(provider.rawValue)SelectedModel"
if let savedModel = userDefaults.string(forKey: key), !savedModel.isEmpty {
selectedModels[provider] = savedModel
}
}
}
private func loadSavedOpenRouterModels() {
if let savedModels = userDefaults.array(forKey: "openRouterModels") as? [String] {
openRouterModels = savedModels
}
}
private func saveOpenRouterModels() {
userDefaults.set(openRouterModels, forKey: "openRouterModels")
}
func selectModel(_ model: String) {
guard !model.isEmpty else { return }
selectedModels[selectedProvider] = model
let key = "\(selectedProvider.rawValue)SelectedModel"
userDefaults.set(model, forKey: key)
if selectedProvider == .ollama {
updateSelectedOllamaModel(model)
}
objectWillChange.send()
NotificationCenter.default.post(name: .AppSettingsDidChange, object: nil)
}
func saveAPIKey(_ key: String, completion: @escaping (Bool, String?) -> Void) {
guard selectedProvider.requiresAPIKey else {
completion(true, nil)
return
}
verifyAPIKey(key) { [weak self] isValid, errorMessage in
guard let self = self else { return }
DispatchQueue.main.async {
if isValid {
self.apiKey = key
self.isAPIKeyValid = true
APIKeyManager.shared.saveAPIKey(key, forProvider: self.selectedProvider.rawValue)
NotificationCenter.default.post(name: .aiProviderKeyChanged, object: nil)
} else {
self.isAPIKeyValid = false
}
completion(isValid, errorMessage)
}
}
}
func verifyAPIKey(_ key: String, completion: @escaping (Bool, String?) -> Void) {
guard selectedProvider.requiresAPIKey else {
completion(true, nil)
return
}
switch selectedProvider {
case .anthropic:
verifyAnthropicAPIKey(key, completion: completion)
case .elevenLabs:
verifyElevenLabsAPIKey(key, completion: completion)
case .deepgram:
verifyDeepgramAPIKey(key, completion: completion)
case .mistral:
verifyMistralAPIKey(key, completion: completion)
case .soniox:
verifySonioxAPIKey(key, completion: completion)
default:
verifyOpenAICompatibleAPIKey(key, completion: completion)
}
}
private func verifyOpenAICompatibleAPIKey(_ key: String, completion: @escaping (Bool, String?) -> Void) {
let url = URL(string: selectedProvider.baseURL)!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.addValue("Bearer \(key)", forHTTPHeaderField: "Authorization")
let testBody: [String: Any] = [
"model": currentModel,
"messages": [
["role": "user", "content": "test"]
]
]
request.httpBody = try? JSONSerialization.data(withJSONObject: testBody)
URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completion(false, error.localizedDescription)
return
}
if let httpResponse = response as? HTTPURLResponse {
let isValid = httpResponse.statusCode == 200
if !isValid {
if let data = data, let responseString = String(data: data, encoding: .utf8) {
completion(false, responseString)
} else {
completion(false, nil)
}
} else {
completion(true, nil)
}
} else {
completion(false, nil)
}
}.resume()
}
private func verifyAnthropicAPIKey(_ key: String, completion: @escaping (Bool, String?) -> Void) {
let url = URL(string: selectedProvider.baseURL)!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.addValue(key, forHTTPHeaderField: "x-api-key")
request.addValue("2023-06-01", forHTTPHeaderField: "anthropic-version")
let testBody: [String: Any] = [
"model": currentModel,
"max_tokens": 1024,
"system": "You are a test system.",
"messages": [
["role": "user", "content": "test"]
]
]
request.httpBody = try? JSONSerialization.data(withJSONObject: testBody)
URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completion(false, error.localizedDescription)
return
}
if let httpResponse = response as? HTTPURLResponse {
if httpResponse.statusCode == 200 {
completion(true, nil)
} else {
if let data = data, let responseString = String(data: data, encoding: .utf8) {
completion(false, responseString)
} else {
completion(false, nil)
}
}
} else {
completion(false, nil)
}
}.resume()
}
private func verifyElevenLabsAPIKey(_ key: String, completion: @escaping (Bool, String?) -> Void) {
let url = URL(string: "https://api.elevenlabs.io/v1/user")!
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.addValue(key, forHTTPHeaderField: "xi-api-key")
URLSession.shared.dataTask(with: request) { data, response, _ in
let isValid = (response as? HTTPURLResponse)?.statusCode == 200
if let data = data, let body = String(data: data, encoding: .utf8) {
if !isValid {
completion(false, body)
return
}
}
completion(isValid, nil)
}.resume()
}
private func verifyMistralAPIKey(_ key: String, completion: @escaping (Bool, String?) -> Void) {
let url = URL(string: "https://api.mistral.ai/v1/models")!
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.addValue("Bearer \(key)", forHTTPHeaderField: "Authorization")
URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completion(false, error.localizedDescription)
return
}
if let httpResponse = response as? HTTPURLResponse {
if httpResponse.statusCode == 200 {
completion(true, nil)
} else {
if let data = data, let body = String(data: data, encoding: .utf8) {
completion(false, body)
} else {
completion(false, nil)
}
}
} else {
completion(false, nil)
}
}.resume()
}
private func verifyDeepgramAPIKey(_ key: String, completion: @escaping (Bool, String?) -> Void) {
let url = URL(string: "https://api.deepgram.com/v1/auth/token")!
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.addValue("Token \(key)", forHTTPHeaderField: "Authorization")
URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completion(false, error.localizedDescription)
return
}
if let httpResponse = response as? HTTPURLResponse {
if httpResponse.statusCode == 200 {
completion(true, nil)
} else {
if let data = data, let responseString = String(data: data, encoding: .utf8) {
completion(false, responseString)
} else {
completion(false, nil)
}
}
} else {
completion(false, nil)
}
}.resume()
}
private func verifySonioxAPIKey(_ key: String, completion: @escaping (Bool, String?) -> Void) {
guard let url = URL(string: "https://api.soniox.com/v1/files") else {
completion(false, nil)
return
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.addValue("Bearer \(key)", forHTTPHeaderField: "Authorization")
request.addValue("application/json", forHTTPHeaderField: "Accept")
URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completion(false, error.localizedDescription)
return
}
if let httpResponse = response as? HTTPURLResponse {
if httpResponse.statusCode == 200 {
completion(true, nil)
} else {
if let data = data, let responseString = String(data: data, encoding: .utf8) {
completion(false, responseString)
} else {
completion(false, nil)
}
}
} else {
completion(false, nil)
}
}.resume()
}
func clearAPIKey() {
guard selectedProvider.requiresAPIKey else { return }
apiKey = ""
isAPIKeyValid = false
APIKeyManager.shared.deleteAPIKey(forProvider: selectedProvider.rawValue)
NotificationCenter.default.post(name: .aiProviderKeyChanged, object: nil)
}
func checkOllamaConnection(completion: @escaping (Bool) -> Void) {
Task { [weak self] in
guard let self = self else { return }
await self.ollamaService.checkConnection()
DispatchQueue.main.async {
completion(self.ollamaService.isConnected)
}
}
}
func fetchOllamaModels() async -> [OllamaService.OllamaModel] {
await ollamaService.refreshModels()
return ollamaService.availableModels
}
func enhanceWithOllama(text: String, systemPrompt: String) async throws -> String {
do {
let result = try await ollamaService.enhance(text, withSystemPrompt: systemPrompt)
return result
} catch {
throw error
}
}
func updateOllamaBaseURL(_ newURL: String) {
ollamaService.baseURL = newURL
userDefaults.set(newURL, forKey: "ollamaBaseURL")
}
func updateSelectedOllamaModel(_ modelName: String) {
ollamaService.selectedModel = modelName
userDefaults.set(modelName, forKey: "ollamaSelectedModel")
}
func fetchOpenRouterModels() async {
let url = URL(string: "https://openrouter.ai/api/v1/models")!
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
await MainActor.run {
self.openRouterModels = []
self.saveOpenRouterModels()
self.objectWillChange.send()
}
return
}
guard let jsonResponse = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let dataArray = jsonResponse["data"] as? [[String: Any]] else {
await MainActor.run {
self.openRouterModels = []
self.saveOpenRouterModels()
self.objectWillChange.send()
}
return
}
let models = dataArray.compactMap { $0["id"] as? String }
await MainActor.run {
self.openRouterModels = models.sorted()
self.saveOpenRouterModels() // Save to UserDefaults
if self.selectedProvider == .openRouter && self.currentModel == self.selectedProvider.defaultModel && !models.isEmpty {
self.selectModel(models.sorted().first!)
}
self.objectWillChange.send()
}
} catch {
await MainActor.run {
self.openRouterModels = []
self.saveOpenRouterModels()
self.objectWillChange.send()
}
}
}
}