From ed107cffa089a0f891628a1819dc78df8372415b Mon Sep 17 00:00:00 2001 From: octo-patch Date: Fri, 17 Apr 2026 22:34:58 +0800 Subject: [PATCH] feat: add MiniMax provider support Add MiniMax as an OpenAI-compatible LLM provider: - Models: MiniMax-M2.7 (default) and MiniMax-M2.7-highspeed - API: https://api.minimax.io/v1 (OpenAI-compatible) - Temperature default: 1.0 (MiniMax requires > 0.0) - Context window: 1,000,000 tokens - Keychain storage for MINIMAX_API_KEY - Full UI: API key field, model picker with refresh, settings section Depends on macOS26/AgentTools#1 for the APIProvider.miniMax enum case. --- .../AgentViewModel/Core/AgentViewModel.swift | 16 +++++++ Agent/AgentViewModel/Core/Colors.swift | 1 + .../Features/DefaultModels.swift | 7 +++ .../Features/ModelFetching.swift | 26 +++++++++++ .../AgentViewModel/Features/ScriptTabs.swift | 5 +++ .../AgentViewModel/TaskExecution/Setup.swift | 3 ++ Agent/Services/KeychainService.swift | 4 ++ Agent/Services/LLMProviderSetup.swift | 13 +++++- .../Views/Output/ThinkingIndicatorView.swift | 1 + Agent/Views/Settings/FallbackChainView.swift | 1 + Agent/Views/Settings/SettingsView.swift | 43 +++++++++++++++++++ Agent/Views/Tabs/NewMainTabSheet.swift | 9 ++++ 12 files changed, 128 insertions(+), 1 deletion(-) diff --git a/Agent/AgentViewModel/Core/AgentViewModel.swift b/Agent/AgentViewModel/Core/AgentViewModel.swift index 8194c477..bc713b26 100644 --- a/Agent/AgentViewModel/Core/AgentViewModel.swift +++ b/Agent/AgentViewModel/Core/AgentViewModel.swift @@ -292,6 +292,19 @@ final class AgentViewModel { var qwenModels: [OpenAIModelInfo] = [] var isFetchingQwenModels = false + // MARK: - MiniMax + + var miniMaxAPIKey: String = KeychainService.shared.getMiniMaxAPIKey() ?? "" { + didSet { KeychainService.shared.setMiniMaxAPIKey(miniMaxAPIKey) } + } + + var miniMaxModel: String = UserDefaults.standard.string(forKey: "miniMaxModel") ?? "MiniMax-M2.7" { + didSet { UserDefaults.standard.set(miniMaxModel, forKey: "miniMaxModel") } + } + + var miniMaxModels: [OpenAIModelInfo] = Self.defaultMiniMaxModels + var isFetchingMiniMaxModels = false + // MARK: - Google Gemini var geminiAPIKey: String = KeychainService.shared.getGeminiAPIKey() ?? "" { @@ -420,6 +433,9 @@ final class AgentViewModel { var grokTemperature: Double = UserDefaults.standard.object(forKey: "grokTemperature") as? Double ?? 0.2 { didSet { UserDefaults.standard.set(grokTemperature, forKey: "grokTemperature") } } + var miniMaxTemperature: Double = UserDefaults.standard.object(forKey: "miniMaxTemperature") as? Double ?? 1.0 { + didSet { UserDefaults.standard.set(miniMaxTemperature, forKey: "miniMaxTemperature") } + } /// Max output tokens per provider. 0 = let provider decide (omit from request). /// Claude API requires max_tokens so 0 defaults to 16384 at the service level. diff --git a/Agent/AgentViewModel/Core/Colors.swift b/Agent/AgentViewModel/Core/Colors.swift index 2d97dfcd..e21dc4ed 100644 --- a/Agent/AgentViewModel/Core/Colors.swift +++ b/Agent/AgentViewModel/Core/Colors.swift @@ -118,6 +118,7 @@ extension AgentViewModel { case .lmStudio: return lmStudioTemperature case .zAI: return zAITemperature case .bigModel: return zAITemperature + case .miniMax: return miniMaxTemperature case .qwen: return openAITemperature case .gemini: return geminiTemperature case .grok: return grokTemperature diff --git a/Agent/AgentViewModel/Features/DefaultModels.swift b/Agent/AgentViewModel/Features/DefaultModels.swift index 01e544b4..b65f9b1d 100644 --- a/Agent/AgentViewModel/Features/DefaultModels.swift +++ b/Agent/AgentViewModel/Features/DefaultModels.swift @@ -120,6 +120,13 @@ extension AgentViewModel { OpenAIModelInfo(id: "mistralai/Mistral-Small-24B-Instruct-2501", name: "Mistral Small 24B"), ] + // MARK: - MiniMax + + nonisolated static let defaultMiniMaxModels: [OpenAIModelInfo] = [ + OpenAIModelInfo(id: "MiniMax-M2.7", name: "MiniMax-M2.7"), + OpenAIModelInfo(id: "MiniMax-M2.7-highspeed", name: "MiniMax-M2.7-highspeed"), + ] + // MARK: - Ollama (Cloud) nonisolated static let defaultOllamaModels: [OllamaModelInfo] = [ diff --git a/Agent/AgentViewModel/Features/ModelFetching.swift b/Agent/AgentViewModel/Features/ModelFetching.swift index 9157c01e..fe6dfb0e 100644 --- a/Agent/AgentViewModel/Features/ModelFetching.swift +++ b/Agent/AgentViewModel/Features/ModelFetching.swift @@ -182,6 +182,31 @@ extension AgentViewModel { } } + func fetchMiniMaxModels() { + guard !miniMaxAPIKey.isEmpty else { + miniMaxModels = Self.defaultMiniMaxModels + return + } + isFetchingMiniMaxModels = true + Task { + defer { isFetchingMiniMaxModels = false } + do { + let models = try await Self.fetchOpenAICompatibleModels( + baseURL: "https://api.minimax.io/v1", + apiKey: miniMaxAPIKey + ) + miniMaxModels = models.isEmpty ? Self.defaultMiniMaxModels : models + let ids = miniMaxModels.map(\.id) + if miniMaxModel.isEmpty || (!ids.isEmpty && !ids.contains(miniMaxModel)) { + miniMaxModel = ids.first ?? "MiniMax-M2.7" + } + } catch { + appendLog("Failed to fetch MiniMax models: \(error.localizedDescription)") + miniMaxModels = Self.defaultMiniMaxModels + } + } + } + // MARK: - Static API Fetch Helpers private nonisolated static func fetchOpenAIModelsFromAPI(apiKey: String) async throws -> [OpenAIModelInfo] { @@ -775,6 +800,7 @@ extension AgentViewModel { case .mistral: if force || mistralModels.isEmpty { fetchMistralModels() } case .codestral: if force || codestralModels.isEmpty { fetchCodestralModels() } case .vibe: if force || vibeModels.isEmpty { fetchVibeModels() } + case .miniMax: if force || miniMaxModels.isEmpty { fetchMiniMaxModels() } case .bigModel: break default: break } diff --git a/Agent/AgentViewModel/Features/ScriptTabs.swift b/Agent/AgentViewModel/Features/ScriptTabs.swift index 37c7cdd1..d3dddc1d 100644 --- a/Agent/AgentViewModel/Features/ScriptTabs.swift +++ b/Agent/AgentViewModel/Features/ScriptTabs.swift @@ -72,6 +72,7 @@ extension AgentViewModel { case .lmStudio: return lmStudioModel case .zAI: return zAIModel.replacingOccurrences(of: ":v", with: "") case .bigModel: return bigModelModel.replacingOccurrences(of: ":v", with: "") + case .miniMax: return miniMaxModel case .qwen: return qwenModel case .gemini: return geminiModel case .grok: return grokModel @@ -95,6 +96,7 @@ extension AgentViewModel { case .lmStudio: return lmStudioAPIKey case .zAI: return zAIAPIKey case .bigModel: return bigModelAPIKey + case .miniMax: return miniMaxAPIKey case .qwen: return qwenAPIKey case .gemini: return geminiAPIKey case .grok: return grokAPIKey @@ -141,6 +143,9 @@ extension AgentViewModel { ?? Self.defaultZAIModels.first(where: { $0.id == modelId })?.name ?? modelId case .bigModel: return modelId + case .miniMax: + return miniMaxModels.first(where: { $0.id == modelId })?.name + ?? Self.defaultMiniMaxModels.first(where: { $0.id == modelId })?.name ?? modelId case .qwen: return modelId case .gemini: diff --git a/Agent/AgentViewModel/TaskExecution/Setup.swift b/Agent/AgentViewModel/TaskExecution/Setup.swift index 7a11a298..9444f5de 100644 --- a/Agent/AgentViewModel/TaskExecution/Setup.swift +++ b/Agent/AgentViewModel/TaskExecution/Setup.swift @@ -56,6 +56,9 @@ extension AgentViewModel { case .bigModel: isVision = bigModelModel.hasSuffix(":v") modelName = bigModelModel.replacingOccurrences(of: ":v", with: "") + case .miniMax: + modelName = miniMaxModel + isVision = false case .qwen: modelName = qwenModel isVision = Self.isVisionModel(qwenModel) diff --git a/Agent/Services/KeychainService.swift b/Agent/Services/KeychainService.swift index 8ee40cbf..0df95b24 100644 --- a/Agent/Services/KeychainService.swift +++ b/Agent/Services/KeychainService.swift @@ -70,6 +70,10 @@ final class KeychainService: Sendable { func setQwenAPIKey(_ key: String) { set(key: Self.qwenAPIKeyId, value: key) } func getQwenAPIKey() -> String? { get(key: Self.qwenAPIKeyId) } + private static let miniMaxAPIKey = "com.agent.minimax-api-key" + func setMiniMaxAPIKey(_ key: String) { set(key: Self.miniMaxAPIKey, value: key) } + func getMiniMaxAPIKey() -> String? { get(key: Self.miniMaxAPIKey) } + private func set(key: String, value: String) { guard let data = value.data(using: .utf8) else { return } delete(key: key) diff --git a/Agent/Services/LLMProviderSetup.swift b/Agent/Services/LLMProviderSetup.swift index 4fdcd939..8fe4f91a 100644 --- a/Agent/Services/LLMProviderSetup.swift +++ b/Agent/Services/LLMProviderSetup.swift @@ -7,7 +7,7 @@ enum LLMProviderSetup { static func registerAllProviders() { LLMRegistry.shared.registerAll([ - claude, openAI, gemini, grok, mistral, codestral, vibe, deepSeek, huggingFace, zAI, bigModel, qwen, + claude, openAI, gemini, grok, mistral, codestral, vibe, deepSeek, huggingFace, miniMax, zAI, bigModel, qwen, ollama, localOllama, vLLM, lmStudio, appleIntelligence ]) } @@ -57,6 +57,17 @@ enum LLMProviderSetup { capabilities: [.streaming, .tools, .systemPrompt] ) + static let miniMax = LLMProviderConfig( + id: "miniMax", displayName: "MiniMax", + kind: .cloudAPI, apiProtocol: .openAI, + endpoint: LLMEndpoint( + chatURL: "https://api.minimax.io/v1/chat/completions", + modelsURL: "https://api.minimax.io/v1/models" + ), + capabilities: [.streaming, .tools, .systemPrompt], + temperature: 1.0 + ) + static let zAI = LLMProviderConfig( id: "zAI", displayName: "Z.ai", kind: .cloudAPI, apiProtocol: .openAI, diff --git a/Agent/Views/Output/ThinkingIndicatorView.swift b/Agent/Views/Output/ThinkingIndicatorView.swift index 258b6159..92067368 100644 --- a/Agent/Views/Output/ThinkingIndicatorView.swift +++ b/Agent/Views/Output/ThinkingIndicatorView.swift @@ -131,6 +131,7 @@ struct ThinkingIndicatorView: View { case .grok: return 2_000_000 case .zAI: return 128_000 case .bigModel: return 128_000 + case .miniMax: return 1_000_000 case .qwen: return 131_072 case .mistral: return 256_000 case .codestral: return 256_000 diff --git a/Agent/Views/Settings/FallbackChainView.swift b/Agent/Views/Settings/FallbackChainView.swift index 0d5af42d..01c2258c 100644 --- a/Agent/Views/Settings/FallbackChainView.swift +++ b/Agent/Views/Settings/FallbackChainView.swift @@ -248,6 +248,7 @@ struct FallbackChainView: View { case .codestral: if !viewModel.codestralModel.isEmpty { return viewModel.codestralModel } case .vibe: if !viewModel.vibeModel.isEmpty { return viewModel.vibeModel } case .bigModel: if !viewModel.bigModelModel.isEmpty { return viewModel.bigModelModel } + case .miniMax: if !viewModel.miniMaxModel.isEmpty { return viewModel.miniMaxModel } case .foundationModel: return "Apple Intelligence" } // Fall back to the first dynamically-fetched model for this provider diff --git a/Agent/Views/Settings/SettingsView.swift b/Agent/Views/Settings/SettingsView.swift index 5ac2ccb0..e837a238 100644 --- a/Agent/Views/Settings/SettingsView.swift +++ b/Agent/Views/Settings/SettingsView.swift @@ -17,6 +17,7 @@ struct SettingsView: View { case .lmStudio: return $viewModel.lmStudioTemperature case .zAI: return $viewModel.zAITemperature case .bigModel: return $viewModel.zAITemperature + case .miniMax: return $viewModel.miniMaxTemperature case .qwen: return $viewModel.openAITemperature case .gemini: return $viewModel.geminiTemperature case .grok: return $viewModel.grokTemperature @@ -275,6 +276,48 @@ struct SettingsView: View { .textFieldStyle(.roundedBorder) } } + } else if viewModel.selectedProvider == .miniMax { + VStack(alignment: .leading, spacing: 10) { + Text("MiniMax API") + .font(.headline) + + VStack(alignment: .leading, spacing: 4) { + Text("API Key").font(.caption).foregroundStyle(.secondary) + LockedSecureField(text: $viewModel.miniMaxAPIKey, placeholder: "MiniMax API key", lockKey: "lock.miniMaxAPIKey") + } + + VStack(alignment: .leading, spacing: 4) { + Text("Model").font(.caption).foregroundStyle(.secondary) + HStack { + if viewModel.miniMaxModels.isEmpty { + TextField("Model name", text: $viewModel.miniMaxModel) + .textFieldStyle(.roundedBorder) + } else { + Picker("Model", selection: $viewModel.miniMaxModel) { + ForEach(viewModel.miniMaxModels) { model in + Text(model.name).tag(model.id) + } + } + .labelsHidden() + } + + Button { + viewModel.fetchMiniMaxModels() + } label: { + if viewModel.isFetchingMiniMaxModels { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "arrow.clockwise") + } + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(viewModel.isFetchingMiniMaxModels) + .help("Fetch available models") + } + } + } } else if viewModel.selectedProvider == .qwen { VStack(alignment: .leading, spacing: 10) { Text("Qwen (DashScope)") diff --git a/Agent/Views/Tabs/NewMainTabSheet.swift b/Agent/Views/Tabs/NewMainTabSheet.swift index dcd10c58..6e97fece 100644 --- a/Agent/Views/Tabs/NewMainTabSheet.swift +++ b/Agent/Views/Tabs/NewMainTabSheet.swift @@ -146,6 +146,14 @@ struct NewMainTabSheet: View { TextField("Model (e.g. glm-4.7)", text: $selectedModelId) .textFieldStyle(.roundedBorder) + case .miniMax: + modelPickerWithFetch( + models: viewModel.miniMaxModels, + fallbackBinding: $selectedModelId, + isFetching: viewModel.isFetchingMiniMaxModels, + fetch: { viewModel.fetchModelsIfNeeded(for: .miniMax, force: true) } + ) + case .qwen: TextField("Model (e.g. qwen-plus)", text: $selectedModelId) .textFieldStyle(.roundedBorder) @@ -286,6 +294,7 @@ struct NewMainTabSheet: View { case .lmStudio: return viewModel.lmStudioModel case .zAI: return viewModel.zAIModel case .bigModel: return "glm-4.7" + case .miniMax: return viewModel.miniMaxModel.isEmpty ? "MiniMax-M2.7" : viewModel.miniMaxModel case .qwen: return "qwen-plus" case .gemini: return viewModel.geminiModel case .grok: return viewModel.grokModel