https://docs.mistral.ai/getting-started/models/models_overview/#api-versioning Medium offers a reasonable balance between Small and Large in output and speed. Especially now when Large is sometimes is frequently over capacity. This is my go to model for Mistral.
565 lines
21 KiB
Swift
565 lines
21 KiB
Swift
import Foundation
|
|
import os
|
|
|
|
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 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 .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 "qwen/qwen3-32b"
|
|
case .gemini:
|
|
return "gemini-2.0-flash-lite"
|
|
case .anthropic:
|
|
return "claude-sonnet-4-0"
|
|
case .openAI:
|
|
return "gpt-5-mini"
|
|
case .mistral:
|
|
return "mistral-large-latest"
|
|
case .elevenLabs:
|
|
return "scribe_v1"
|
|
case .deepgram:
|
|
return "whisper-1"
|
|
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 [
|
|
"llama-4-scout-17b-16e-instruct",
|
|
"llama-3.3-70b",
|
|
"gpt-oss-120b",
|
|
"qwen-3-32b",
|
|
"qwen-3-235b-a22b-instruct-2507"
|
|
]
|
|
case .groq:
|
|
return [
|
|
"llama-3.3-70b-versatile",
|
|
"moonshotai/kimi-k2-instruct",
|
|
"qwen/qwen3-32b",
|
|
"meta-llama/llama-4-maverick-17b-128e-instruct",
|
|
"openai/gpt-oss-120b"
|
|
]
|
|
case .gemini:
|
|
return [
|
|
"gemini-2.5-pro",
|
|
"gemini-2.5-flash",
|
|
"gemini-2.5-flash-lite",
|
|
"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 .openAI:
|
|
return [
|
|
"gpt-5",
|
|
"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 .ollama:
|
|
return []
|
|
case .custom:
|
|
return []
|
|
case .openRouter:
|
|
return []
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|
|
}
|
|
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 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()
|
|
NotificationCenter.default.post(name: .AppSettingsDidChange, object: nil)
|
|
}
|
|
|
|
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 .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"]
|
|
]
|
|
]
|
|
|
|
request.httpBody = try? JSONSerialization.data(withJSONObject: testBody)
|
|
|
|
logger.notice("🔑 Verifying API key for \(self.selectedProvider.rawValue, privacy: .public) provider at \(url.absoluteString, privacy: .public)")
|
|
|
|
URLSession.shared.dataTask(with: request) { data, response, error in
|
|
if let error = error {
|
|
self.logger.notice("🔑 API key verification failed for \(self.selectedProvider.rawValue, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
|
completion(false)
|
|
return
|
|
}
|
|
|
|
if let httpResponse = response as? HTTPURLResponse {
|
|
let isValid = httpResponse.statusCode == 200
|
|
|
|
if !isValid {
|
|
// Log the exact API error response
|
|
if let data = data, let exactAPIError = String(data: data, encoding: .utf8) {
|
|
self.logger.notice("🔑 API key verification failed for \(self.selectedProvider.rawValue, privacy: .public) - Status: \(httpResponse.statusCode) - \(exactAPIError, privacy: .public)")
|
|
} else {
|
|
self.logger.notice("🔑 API key verification failed for \(self.selectedProvider.rawValue, privacy: .public) - Status: \(httpResponse.statusCode)")
|
|
}
|
|
}
|
|
|
|
completion(isValid)
|
|
} else {
|
|
self.logger.notice("🔑 API key verification failed for \(self.selectedProvider.rawValue, privacy: .public): Invalid response")
|
|
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 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()
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
|