601 lines
21 KiB
Swift
601 lines
21 KiB
Swift
import Foundation
|
|
import os
|
|
|
|
enum AIProvider: String, CaseIterable {
|
|
case groq = "GROQ"
|
|
case openAI = "OpenAI"
|
|
case deepSeek = "DeepSeek"
|
|
case gemini = "Gemini"
|
|
case anthropic = "Anthropic"
|
|
case openRouter = "OpenRouter"
|
|
case mistral = "Mistral"
|
|
case ollama = "Ollama"
|
|
case elevenLabs = "ElevenLabs"
|
|
case deepgram = "Deepgram"
|
|
case custom = "Custom"
|
|
case cerebras = "Cerebras"
|
|
|
|
|
|
var baseURL: String {
|
|
switch self {
|
|
case .groq:
|
|
return "https://api.groq.com/openai/v1/chat/completions"
|
|
case .openAI:
|
|
return "https://api.openai.com/v1/chat/completions"
|
|
case .deepSeek:
|
|
return "https://api.deepseek.com/v1/chat/completions"
|
|
case .gemini:
|
|
return "https://generativelanguage.googleapis.com/v1beta/models"
|
|
case .anthropic:
|
|
return "https://api.anthropic.com/v1/messages"
|
|
case .openRouter:
|
|
return "https://openrouter.ai/api/v1/chat/completions"
|
|
case .mistral:
|
|
return "https://api.mistral.ai/v1/audio/transcriptions"
|
|
case .elevenLabs:
|
|
return "https://api.elevenlabs.io/v1/speech-to-text"
|
|
case .ollama:
|
|
return UserDefaults.standard.string(forKey: "ollamaBaseURL") ?? "http://localhost:11434"
|
|
case .deepgram:
|
|
return "https://api.deepgram.com/v1/listen"
|
|
case .custom:
|
|
return UserDefaults.standard.string(forKey: "customProviderBaseURL") ?? ""
|
|
case .cerebras:
|
|
return "https://api.cerebras.ai/v1/chat/completions"
|
|
|
|
}
|
|
}
|
|
|
|
var defaultModel: String {
|
|
switch self {
|
|
case .groq:
|
|
return "qwen/qwen3-32b"
|
|
case .openAI:
|
|
return "gpt-4.1-mini"
|
|
case .deepSeek:
|
|
return "deepseek-chat"
|
|
case .gemini:
|
|
return "gemini-2.0-flash-lite"
|
|
case .anthropic:
|
|
return "claude-sonnet-4-0"
|
|
case .mistral:
|
|
return "mistral-large-latest"
|
|
case .elevenLabs:
|
|
return "scribe_v1"
|
|
case .ollama:
|
|
return UserDefaults.standard.string(forKey: "ollamaSelectedModel") ?? "mistral"
|
|
case .deepgram:
|
|
return "whisper-1"
|
|
case .custom:
|
|
return UserDefaults.standard.string(forKey: "customProviderModel") ?? ""
|
|
case .openRouter:
|
|
return "openai/gpt-4o"
|
|
case .cerebras:
|
|
return "qwen-3-32b"
|
|
}
|
|
}
|
|
|
|
var availableModels: [String] {
|
|
switch self {
|
|
case .groq:
|
|
return [
|
|
"llama-3.3-70b-versatile",
|
|
"moonshotai/kimi-k2-instruct",
|
|
"qwen/qwen3-32b",
|
|
"meta-llama/llama-4-maverick-17b-128e-instruct"
|
|
]
|
|
case .openAI:
|
|
return [
|
|
"gpt-4.1",
|
|
"gpt-4.1-mini"
|
|
]
|
|
case .deepSeek:
|
|
return [
|
|
"deepseek-chat",
|
|
"deepseek-reasoner"
|
|
]
|
|
case .gemini:
|
|
return [
|
|
"gemini-2.5-pro",
|
|
"gemini-2.5-flash",
|
|
"gemini-2.0-flash",
|
|
"gemini-2.0-flash-lite"
|
|
]
|
|
case .anthropic:
|
|
return [
|
|
"claude-opus-4-0",
|
|
"claude-sonnet-4-0",
|
|
"claude-3-7-sonnet-latest",
|
|
"claude-3-5-haiku-latest",
|
|
"claude-3-5-sonnet-latest"
|
|
]
|
|
case .mistral:
|
|
return [
|
|
"mistral-large-latest",
|
|
"mistral-small-latest",
|
|
"mistral-saba-latest"
|
|
]
|
|
case .elevenLabs:
|
|
return ["scribe_v1", "scribe_v1_experimental"]
|
|
case .ollama:
|
|
return []
|
|
case .deepgram:
|
|
return ["whisper-1"]
|
|
case .custom:
|
|
return []
|
|
case .openRouter:
|
|
return []
|
|
case .cerebras:
|
|
return [
|
|
"llama-4-scout-17b-16e-instruct",
|
|
"llama-3.3-70b",
|
|
"qwen-3-32b",
|
|
"qwen-3-235b-a22b"
|
|
]
|
|
}
|
|
}
|
|
|
|
var requiresAPIKey: Bool {
|
|
switch self {
|
|
case .ollama:
|
|
return false
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
class AIService: ObservableObject {
|
|
private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "AIService")
|
|
|
|
@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 = userDefaults.string(forKey: "\(selectedProvider.rawValue)APIKey") {
|
|
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()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@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 userDefaults.string(forKey: "\(provider.rawValue)APIKey") != nil
|
|
}
|
|
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 = userDefaults.string(forKey: "\(selectedProvider.rawValue)APIKey") {
|
|
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()
|
|
}
|
|
|
|
func saveAPIKey(_ key: String, completion: @escaping (Bool) -> Void) {
|
|
guard selectedProvider.requiresAPIKey else {
|
|
completion(true)
|
|
return
|
|
}
|
|
|
|
verifyAPIKey(key) { [weak self] isValid in
|
|
guard let self = self else { return }
|
|
DispatchQueue.main.async {
|
|
if isValid {
|
|
self.apiKey = key
|
|
self.isAPIKeyValid = true
|
|
self.userDefaults.set(key, forKey: "\(self.selectedProvider.rawValue)APIKey")
|
|
NotificationCenter.default.post(name: .aiProviderKeyChanged, object: nil)
|
|
} else {
|
|
self.isAPIKeyValid = false
|
|
}
|
|
completion(isValid)
|
|
}
|
|
}
|
|
}
|
|
|
|
func verifyAPIKey(_ key: String, completion: @escaping (Bool) -> Void) {
|
|
guard selectedProvider.requiresAPIKey else {
|
|
completion(true)
|
|
return
|
|
}
|
|
|
|
switch selectedProvider {
|
|
case .gemini:
|
|
verifyGeminiAPIKey(key, completion: completion)
|
|
case .anthropic:
|
|
verifyAnthropicAPIKey(key, completion: completion)
|
|
case .elevenLabs:
|
|
verifyElevenLabsAPIKey(key, completion: completion)
|
|
case .deepgram:
|
|
verifyDeepgramAPIKey(key, completion: completion)
|
|
case .mistral:
|
|
verifyMistralAPIKey(key, completion: completion)
|
|
default:
|
|
verifyOpenAICompatibleAPIKey(key, completion: completion)
|
|
}
|
|
}
|
|
|
|
private func verifyOpenAICompatibleAPIKey(_ key: String, completion: @escaping (Bool) -> 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"]
|
|
],
|
|
"max_tokens": 1
|
|
]
|
|
|
|
request.httpBody = try? JSONSerialization.data(withJSONObject: testBody)
|
|
|
|
URLSession.shared.dataTask(with: request) { data, response, error in
|
|
if let error = error {
|
|
completion(false)
|
|
return
|
|
}
|
|
|
|
if let httpResponse = response as? HTTPURLResponse {
|
|
completion(httpResponse.statusCode == 200)
|
|
} else {
|
|
completion(false)
|
|
}
|
|
}.resume()
|
|
}
|
|
|
|
private func verifyAnthropicAPIKey(_ key: String, completion: @escaping (Bool) -> 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)
|
|
return
|
|
}
|
|
|
|
if let httpResponse = response as? HTTPURLResponse {
|
|
completion(httpResponse.statusCode == 200)
|
|
} else {
|
|
completion(false)
|
|
}
|
|
}.resume()
|
|
}
|
|
|
|
private func verifyGeminiAPIKey(_ key: String, completion: @escaping (Bool) -> Void) {
|
|
let baseEndpoint = "https://generativelanguage.googleapis.com/v1beta/models"
|
|
let model = currentModel
|
|
let fullURL = "\(baseEndpoint)/\(model):generateContent"
|
|
|
|
var urlComponents = URLComponents(string: fullURL)!
|
|
urlComponents.queryItems = [URLQueryItem(name: "key", value: key)]
|
|
|
|
guard let url = urlComponents.url else {
|
|
completion(false)
|
|
return
|
|
}
|
|
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
|
|
let testBody: [String: Any] = [
|
|
"contents": [
|
|
[
|
|
"parts": [
|
|
["text": "test"]
|
|
]
|
|
]
|
|
]
|
|
]
|
|
|
|
request.httpBody = try? JSONSerialization.data(withJSONObject: testBody)
|
|
|
|
URLSession.shared.dataTask(with: request) { data, response, error in
|
|
if let error = error {
|
|
completion(false)
|
|
return
|
|
}
|
|
|
|
if let httpResponse = response as? HTTPURLResponse {
|
|
completion(httpResponse.statusCode == 200)
|
|
} else {
|
|
completion(false)
|
|
}
|
|
}.resume()
|
|
}
|
|
|
|
private func verifyElevenLabsAPIKey(_ key: String, completion: @escaping (Bool) -> 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) {
|
|
self.logger.info("ElevenLabs verification response: \(body)")
|
|
}
|
|
|
|
completion(isValid)
|
|
}.resume()
|
|
}
|
|
|
|
private func verifyMistralAPIKey(_ key: String, completion: @escaping (Bool) -> 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 {
|
|
self.logger.error("Mistral API key verification failed: \(error.localizedDescription)")
|
|
completion(false)
|
|
return
|
|
}
|
|
|
|
if let httpResponse = response as? HTTPURLResponse {
|
|
if httpResponse.statusCode == 200 {
|
|
completion(true)
|
|
} else {
|
|
if let data = data, let body = String(data: data, encoding: .utf8) {
|
|
self.logger.error("Mistral API key verification failed with status code \(httpResponse.statusCode): \(body)")
|
|
} else {
|
|
self.logger.error("Mistral API key verification failed with status code \(httpResponse.statusCode) and no response body.")
|
|
}
|
|
completion(false)
|
|
}
|
|
} else {
|
|
self.logger.error("Mistral API key verification failed: Invalid response from server.")
|
|
completion(false)
|
|
}
|
|
}.resume()
|
|
}
|
|
|
|
private func verifyDeepgramAPIKey(_ key: String, completion: @escaping (Bool) -> 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 {
|
|
self.logger.error("Deepgram API key verification failed: \(error.localizedDescription)")
|
|
completion(false)
|
|
return
|
|
}
|
|
|
|
if let httpResponse = response as? HTTPURLResponse {
|
|
completion(httpResponse.statusCode == 200)
|
|
} else {
|
|
completion(false)
|
|
}
|
|
}.resume()
|
|
}
|
|
|
|
func clearAPIKey() {
|
|
guard selectedProvider.requiresAPIKey else { return }
|
|
|
|
apiKey = ""
|
|
isAPIKeyValid = false
|
|
userDefaults.removeObject(forKey: "\(selectedProvider.rawValue)APIKey")
|
|
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 {
|
|
logger.notice("🔄 Sending transcription to Ollama for enhancement (model: \(self.ollamaService.selectedModel))")
|
|
do {
|
|
let result = try await ollamaService.enhance(text, withSystemPrompt: systemPrompt)
|
|
logger.notice("✅ Ollama enhancement completed successfully (\(result.count) characters)")
|
|
return result
|
|
} catch {
|
|
logger.notice("❌ Ollama enhancement failed: \(error.localizedDescription)")
|
|
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 {
|
|
logger.error("Failed to fetch OpenRouter models: Invalid HTTP response")
|
|
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 {
|
|
logger.error("Failed to parse OpenRouter models JSON")
|
|
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()
|
|
}
|
|
logger.info("Successfully fetched \(models.count) OpenRouter models.")
|
|
|
|
} catch {
|
|
logger.error("Error fetching OpenRouter models: \(error.localizedDescription)")
|
|
await MainActor.run {
|
|
self.openRouterModels = []
|
|
self.saveOpenRouterModels()
|
|
self.objectWillChange.send()
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
extension Notification.Name {
|
|
static let aiProviderKeyChanged = Notification.Name("aiProviderKeyChanged")
|
|
}
|
|
|