Compare commits

...

1 Commits

Author SHA1 Message Date
Peter Steinberger
08e6d1aea6
feat: add MiniMax China provider 2026-05-25 09:58:17 +01:00
15 changed files with 299 additions and 21 deletions

View File

@ -376,6 +376,7 @@ struct AICLI {
case .anthropic: .anthropic
case .google: .google
case .minimax: .minimax
case .minimaxCN: .minimaxCN
case .mistral: .mistral
case .groq: .groq
case .grok: .grok
@ -409,6 +410,11 @@ struct AICLI {
case .minimax:
print("Set your MiniMax API key:")
print("export MINIMAX_API_KEY='your-key-here'")
case .minimaxCN:
print("Set your MiniMax China API key:")
print("export MINIMAX_CN_API_KEY='your-key-here'")
print("# or reuse the global MiniMax key name:")
print("export MINIMAX_API_KEY='your-key-here'")
case .mistral:
print("Set your Mistral API key:")
print("export MISTRAL_API_KEY='your-key-here'")

View File

@ -501,6 +501,7 @@ struct AgentCLI: AsyncParsableCommand {
case .anthropic: .anthropic
case .google: .google
case .minimax: .minimax
case .minimaxCN: .minimaxCN
case .mistral: .mistral
case .groq: .groq
case .grok: .grok
@ -550,7 +551,7 @@ enum CLIError: LocalizedError {
/// Extension to add provider helpers
extension Provider {
static var allStandard: [Provider] {
[.openai, .anthropic, .google, .minimax, .mistral, .groq, .grok, .ollama]
[.openai, .anthropic, .google, .minimax, .minimaxCN, .mistral, .groq, .grok, .ollama]
}
var displayName: String {
@ -559,6 +560,7 @@ extension Provider {
case .anthropic: "Anthropic"
case .google: "Google"
case .minimax: "MiniMax"
case .minimaxCN: "MiniMax China"
case .mistral: "Mistral"
case .groq: "Groq"
case .grok: "Grok"

View File

@ -142,6 +142,10 @@ public final class TachikomaConfiguration: @unchecked Sendable {
return configuredKey
}
if provider == .minimaxCN, let sharedMiniMaxKey = self._apiKeys[Provider.minimax.identifier] {
return sharedMiniMaxKey
}
// Fall back to environment variable only if loadFromEnvironment is true
if self._loadFromEnvironment {
return provider.loadAPIKeyFromEnvironment()
@ -342,6 +346,7 @@ public final class TachikomaConfiguration: @unchecked Sendable {
.openai: "OPENAI_BASE_URL",
.anthropic: "ANTHROPIC_BASE_URL",
.minimax: "MINIMAX_BASE_URL",
.minimaxCN: "MINIMAX_CN_BASE_URL",
.ollama: "OLLAMA_BASE_URL",
.azureOpenAI: "AZURE_OPENAI_ENDPOINT",
]
@ -434,7 +439,8 @@ public final class TachikomaConfiguration: @unchecked Sendable {
self.lock.withLock {
for (provider, key) in self._apiKeys {
let envVarName = "\(provider.uppercased())_API_KEY"
let standardEnvVar = Provider.from(identifier: provider).environmentVariable
let envVarName = standardEnvVar.isEmpty ? "\(provider.uppercased())_API_KEY" : standardEnvVar
lines.append("\(envVarName)=\(key)")
}
}

View File

@ -208,6 +208,8 @@ public final class ModelCapabilityRegistry: @unchecked Sendable {
"lmstudio:\(submodel.modelId)"
case let .minimax(submodel):
"minimax:\(submodel.modelId)"
case let .minimaxCN(submodel):
"minimax-cn:\(submodel.modelId)"
case let .openRouter(modelId):
"openrouter:\(modelId)"
case let .together(modelId):
@ -358,7 +360,7 @@ public final class ModelCapabilityRegistry: @unchecked Sendable {
supportsSeed: true,
)
case .minimax:
case .minimax, .minimaxCN:
return ModelParameterCapabilities()
default:

View File

@ -44,6 +44,9 @@ public enum Provider: Sendable, Hashable, Codable {
/// MiniMax provider (Anthropic-compatible hosted models)
case minimax
/// MiniMax China provider (Anthropic-compatible hosted models)
case minimaxCN
/// Ollama provider (local model hosting)
case ollama
@ -66,6 +69,7 @@ public enum Provider: Sendable, Hashable, Codable {
case .mistral: "mistral"
case .google: "google"
case .minimax: "minimax"
case .minimaxCN: "minimax-cn"
case .ollama: "ollama"
case .lmstudio: "lmstudio"
case .azureOpenAI: "azure-openai"
@ -83,6 +87,7 @@ public enum Provider: Sendable, Hashable, Codable {
case .mistral: "Mistral"
case .google: "Google"
case .minimax: "MiniMax"
case .minimaxCN: "MiniMax China"
case .ollama: "Ollama"
case .lmstudio: "LMStudio"
case .azureOpenAI: "Azure OpenAI"
@ -100,6 +105,7 @@ public enum Provider: Sendable, Hashable, Codable {
case .mistral: "MISTRAL_API_KEY"
case .google: "GEMINI_API_KEY"
case .minimax: "MINIMAX_API_KEY"
case .minimaxCN: "MINIMAX_CN_API_KEY"
case .ollama: "OLLAMA_API_KEY"
case .lmstudio: "" // LMStudio doesn't need API keys
case .azureOpenAI: "AZURE_OPENAI_API_KEY"
@ -112,6 +118,7 @@ public enum Provider: Sendable, Hashable, Codable {
switch self {
case .grok: ["XAI_API_KEY", "GROK_API_KEY"] // Additional Grok aliases
case .google: ["GOOGLE_API_KEY"] // Backwards compatibility
case .minimaxCN: ["MINIMAX_API_KEY"]
case .azureOpenAI: ["AZURE_OPENAI_TOKEN", "AZURE_OPENAI_BEARER_TOKEN"]
default: []
}
@ -127,6 +134,7 @@ public enum Provider: Sendable, Hashable, Codable {
case .mistral: "https://api.mistral.ai/v1"
case .google: "https://generativelanguage.googleapis.com/v1beta"
case .minimax: "https://api.minimax.io/anthropic"
case .minimaxCN: "https://api.minimaxi.com/anthropic"
case .ollama: "http://localhost:11434"
case .lmstudio: "http://localhost:1234/v1"
case .azureOpenAI: nil // Requires resource or endpoint
@ -146,7 +154,7 @@ public enum Provider: Sendable, Hashable, Codable {
/// All standard providers (excludes custom)
public static var standardProviders: [Provider] {
[.openai, .anthropic, .grok, .groq, .mistral, .google, .minimax, .ollama, .azureOpenAI]
[.openai, .anthropic, .grok, .groq, .mistral, .google, .minimax, .minimaxCN, .ollama, .azureOpenAI]
}
/// Create provider from string identifier
@ -160,6 +168,7 @@ public enum Provider: Sendable, Hashable, Codable {
case "mistral": .mistral
case "google", "gemini": .google
case "minimax": .minimax
case "minimax-cn", "minimax_cn", "minimaxi": .minimaxCN
case "ollama": .ollama
case "azure-openai", "azure_openai", "azureopenai": .azureOpenAI
default: .custom(identifier)

View File

@ -17,6 +17,7 @@ public enum LanguageModel: Sendable, CustomStringConvertible, Hashable {
case ollama(Ollama)
case lmstudio(LMStudio)
case minimax(MiniMax)
case minimaxCN(MiniMax)
case azureOpenAI(deployment: String, resource: String? = nil, apiVersion: String? = nil, endpoint: String? = nil)
// Third-party aggregators
@ -660,6 +661,8 @@ public enum LanguageModel: Sendable, CustomStringConvertible, Hashable {
return "LMStudio/\(model.modelId)"
case let .minimax(model):
return "MiniMax/\(model.modelId)"
case let .minimaxCN(model):
return "MiniMax China/\(model.modelId)"
case let .azureOpenAI(deployment, resource, apiVersion, endpoint):
let host = endpoint ?? resource ?? "endpoint"
let version = apiVersion ?? "api-version-default"
@ -699,6 +702,8 @@ public enum LanguageModel: Sendable, CustomStringConvertible, Hashable {
model.modelId
case let .minimax(model):
model.modelId
case let .minimaxCN(model):
model.modelId
case let .azureOpenAI(deployment, _, _, _):
deployment
case let .openRouter(modelId):
@ -736,6 +741,8 @@ public enum LanguageModel: Sendable, CustomStringConvertible, Hashable {
model.supportsVision
case let .minimax(model):
model.supportsVision
case let .minimaxCN(model):
model.supportsVision
case .azureOpenAI:
true // Azure mirrors OpenAI models with vision support when available
case .openRouter, .together, .replicate:
@ -772,6 +779,8 @@ public enum LanguageModel: Sendable, CustomStringConvertible, Hashable {
"LMStudio"
case .minimax:
"MiniMax"
case .minimaxCN:
"MiniMax China"
case .openRouter:
"OpenRouter"
case .together:
@ -827,6 +836,8 @@ extension LanguageModel {
false // LMStudio doesn't support audio input
case let .minimax(model):
model.supportsAudioInput
case let .minimaxCN(model):
model.supportsAudioInput
case .azureOpenAI:
false // Azure chat endpoints currently omit audio input
case .openRouter, .together, .replicate:
@ -858,6 +869,8 @@ extension LanguageModel {
false // LMStudio doesn't support audio output
case let .minimax(model):
model.supportsAudioOutput
case let .minimaxCN(model):
model.supportsAudioOutput
case .azureOpenAI:
false // Azure chat endpoints currently omit audio output
case .openRouter, .together, .replicate:
@ -889,6 +902,8 @@ extension LanguageModel {
model.supportsTools
case let .minimax(model):
model.supportsTools
case let .minimaxCN(model):
model.supportsTools
case .azureOpenAI:
true // Azure OpenAI mirrors OpenAI tool support
case .openRouter, .together, .replicate:
@ -920,6 +935,8 @@ extension LanguageModel {
model.contextLength
case let .minimax(model):
model.contextLength
case let .minimaxCN(model):
model.contextLength
case .azureOpenAI:
128_000 // conservative default matching OpenAI tier
case .openRouter, .together, .replicate:
@ -964,6 +981,9 @@ extension LanguageModel {
case let .minimax(model):
hasher.combine("minimax")
hasher.combine(model)
case let .minimaxCN(model):
hasher.combine("minimax-cn")
hasher.combine(model)
case let .openRouter(modelId):
hasher.combine("openRouter")
hasher.combine(modelId)
@ -1014,6 +1034,8 @@ extension LanguageModel {
lhsModel == rhsModel
case let (.minimax(lhsModel), .minimax(rhsModel)):
lhsModel == rhsModel
case let (.minimaxCN(lhsModel), .minimaxCN(rhsModel)):
lhsModel == rhsModel
case let (.openRouter(lhsId), .openRouter(rhsId)):
lhsId == rhsId
case let (.together(lhsId), .together(rhsId)):
@ -1083,6 +1105,13 @@ extension LanguageModel {
return Self.parseMiniMaxModelIdentifier(qualified.model).map(LanguageModel.minimax)
}
if
let qualified = ProviderParser.parse(trimmed),
["minimax-cn", "minimax_cn", "minimaxi"].contains(qualified.provider.lowercased())
{
return Self.parseMiniMaxModelIdentifier(qualified.model).map(LanguageModel.minimaxCN)
}
if let qualified = ProviderParser.parse(trimmed) {
let provider = qualified.provider.lowercased()
if provider == "openrouter" {
@ -1277,6 +1306,26 @@ extension LanguageModel {
// MARK: MiniMax models
if
dashed.contains("minimax-cn-m2.7-highspeed") ||
dotted.contains("minimax-cn-m2-7-highspeed") ||
compact.contains("minimaxcnm27highspeed") ||
compact.contains("minimaxim27highspeed")
{
return .minimaxCN(.m27Highspeed)
}
if
dashed == "minimax-cn" ||
dashed == "minimaxi" ||
dashed.contains("minimax-cn-m2.7") ||
dotted.contains("minimax-cn-m2-7") ||
compact.contains("minimaxcnm27") ||
compact.contains("minimaxim27")
{
return .minimaxCN(.m27)
}
if
dashed.contains("minimax-m2.7-highspeed") ||
dotted.contains("minimax-m2-7-highspeed") ||

View File

@ -31,6 +31,10 @@ public struct ModelSelector {
}
// MiniMax shortcuts and models
if let miniMaxModel = parseMiniMaxCNModel(normalized) {
return .minimaxCN(miniMaxModel)
}
if let miniMaxModel = parseMiniMaxModel(normalized) {
return .minimax(miniMaxModel)
}
@ -256,6 +260,28 @@ public struct ModelSelector {
}
}
private static func parseMiniMaxCNModel(_ input: String) -> Model.MiniMax? {
switch input {
case "minimax-cn-m2.7", "minimax-cn-m2-7", "minimaxi-m2.7", "minimaxi-m2-7",
"minimax_cn/m2.7", "minimax_cn/m2-7", "minimax_cn/minimax-m2.7",
"minimax_cn/minimax-m2-7",
"minimax-cn/m2.7", "minimax-cn/m2-7", "minimax-cn/minimax-m2.7",
"minimax-cn/minimax-m2-7", "minimaxi/m2.7", "minimaxi/m2-7":
.m27
case "minimax-cn-m2.7-highspeed", "minimax-cn-m2-7-highspeed", "minimaxi-m2.7-highspeed",
"minimaxi-m2-7-highspeed", "minimax-cn/m2.7-highspeed", "minimax-cn/m2-7-highspeed",
"minimax_cn/m2.7-highspeed", "minimax_cn/m2-7-highspeed",
"minimax_cn/minimax-m2.7-highspeed", "minimax_cn/minimax-m2-7-highspeed",
"minimax-cn/minimax-m2.7-highspeed", "minimax-cn/minimax-m2-7-highspeed",
"minimaxi/m2.7-highspeed", "minimaxi/m2-7-highspeed":
.m27Highspeed
case "minimax-cn", "minimax_cn", "minimaxi":
.m27
default:
nil
}
}
private static func isUnsupportedLegacyGrokModel(_ input: String) -> Bool {
let normalized = input.lowercased()
return normalized.hasPrefix("grok-2") ||
@ -369,7 +395,7 @@ public struct ModelSelector {
}
case "google", "gemini":
return Model.Google.allCases.map(\.userFacingModelId)
case "minimax":
case "minimax", "minimax-cn", "minimaxi":
return Model.MiniMax.allCases.map(\.modelId)
case "ollama":
return Model.Ollama.allCases.compactMap {
@ -456,6 +482,11 @@ public func getAllAvailableModels() -> String {
models: ModelSelector.availableModels(for: "minimax"),
)
output += formatModelList(
title: "MiniMax China",
models: ModelSelector.availableModels(for: "minimax-cn"),
)
output += formatModelList(
title: "Grok (xAI)",
models: ModelSelector.availableModels(for: "grok"),
@ -471,6 +502,7 @@ public func getAllAvailableModels() -> String {
output += " • gpt → gpt-5.5\n"
output += " • gemini → gemini-3.1-pro-preview\n"
output += " • minimax → MiniMax-M2.7\n"
output += " • minimax-cn → MiniMax-M2.7 via api.minimaxi.com\n"
output += " • grok → grok-4.3\n"
output += " • llama, llama3 → llama3.3\n"

View File

@ -60,19 +60,22 @@ public struct ProviderFactory {
guard let apiKey = configuration.getAPIKey(for: .minimax) else {
throw TachikomaError.authenticationFailed("MINIMAX_API_KEY not found")
}
return try AnthropicCompatibleProvider(
modelId: minimaxModel.modelId,
baseURL: configuration.getBaseURL(for: .minimax) ?? "https://api.minimax.io/anthropic",
configuration: configuration,
return try Self.makeMiniMaxProvider(
model: minimaxModel,
provider: .minimax,
apiKey: apiKey,
auth: .bearer(apiKey, betaHeader: nil),
capabilities: ModelCapabilities(
supportsVision: minimaxModel.supportsVision,
supportsTools: minimaxModel.supportsTools,
supportsStreaming: true,
contextLength: minimaxModel.contextLength,
maxOutputTokens: 8192,
),
configuration: configuration,
)
case let .minimaxCN(minimaxModel):
guard let apiKey = configuration.getAPIKey(for: .minimaxCN) ?? configuration.getAPIKey(for: .minimax) else {
throw TachikomaError.authenticationFailed("MINIMAX_CN_API_KEY or MINIMAX_API_KEY not found")
}
return try Self.makeMiniMaxProvider(
model: minimaxModel,
provider: .minimaxCN,
apiKey: apiKey,
configuration: configuration,
)
case let .openRouter(modelId):
@ -127,6 +130,32 @@ public struct ProviderFactory {
return provider
}
}
private static func makeMiniMaxProvider(
model: LanguageModel.MiniMax,
provider: Provider,
apiKey: String,
configuration: TachikomaConfiguration,
) throws
-> any ModelProvider
{
try AnthropicCompatibleProvider(
modelId: model.modelId,
baseURL: configuration.getBaseURL(for: provider) ?? provider
.defaultBaseURL ?? "https://api.minimax.io/anthropic",
configuration: configuration,
apiKey: apiKey,
// MiniMax's Anthropic-compatible setup uses Claude Code-style Authorization auth, not Anthropic x-api-key.
auth: .bearer(apiKey, betaHeader: nil),
capabilities: ModelCapabilities(
supportsVision: model.supportsVision,
supportsTools: model.supportsTools,
supportsStreaming: true,
contextLength: model.contextLength,
maxOutputTokens: 8192,
),
)
}
}
// MARK: - Third-Party Aggregators

View File

@ -130,6 +130,8 @@ public enum ProviderParser {
environmentModel = self.parseGoogleModel(config.model)
case "minimax" where hasMiniMax:
environmentModel = self.parseMiniMaxModel(config.model)
case "minimax-cn" where hasMiniMax, "minimax_cn" where hasMiniMax, "minimaxi" where hasMiniMax:
environmentModel = self.parseMiniMaxCNModel(config.model)
case "grok" where hasGrok, "xai" where hasGrok:
environmentModel = self.parseGrokModel(config.model)
case "ollama" where hasOllama:
@ -314,6 +316,17 @@ public enum ProviderParser {
}
}
private static func parseMiniMaxCNModel(_ modelString: String) -> LanguageModel? {
switch modelString.lowercased() {
case "minimax-m2.7", "minimax-m2-7", "m2.7", "m2-7":
.minimaxCN(.m27)
case "minimax-m2.7-highspeed", "minimax-m2-7-highspeed", "m2.7-highspeed", "m2-7-highspeed":
.minimaxCN(.m27Highspeed)
default:
nil
}
}
private static func parseGrokModel(_ modelString: String) -> LanguageModel? {
switch modelString.lowercased() {
case "grok-4.3", "grok-4-3", "grok43", "grok-latest":

View File

@ -563,7 +563,7 @@ public struct ModelCostCalculator: Sendable {
case .mistral: (2.00, 6.00)
case .groq: (0.27, 0.27) // Groq has very low pricing
case .grok: (2.00, 8.00)
case .minimax: (0.30, 1.20)
case .minimax, .minimaxCN: (0.30, 1.20)
case .ollama: (0.00, 0.00) // Local inference
case .lmstudio: (0.00, 0.00) // Local inference
case .openRouter, .together, .replicate: (1.00, 3.00) // Typical aggregator pricing

View File

@ -66,6 +66,51 @@ struct CredentialLoadingTests {
#endif
}
@Test
func `MiniMax China credentials save and reload with canonical env name`() async throws {
#if !os(Windows)
try await TestEnvironmentMutex.shared.withLock {
let originalProfileDirectory = TachikomaConfiguration.profileDirectoryName
let profilePath = FileManager.default.temporaryDirectory
.appendingPathComponent("tachikoma-minimax-cn-credentials-\(UUID().uuidString)")
.path
let credentialPath = "\(profilePath)/credentials"
let savedEnvironment = self.savedEnvironment(for: ["MINIMAX_CN_API_KEY", "MINIMAX_API_KEY"])
TachikomaConfiguration.profileDirectoryName = profilePath
for (key, _) in savedEnvironment {
unsetenv(key)
}
defer {
self.restoreEnvironment(savedEnvironment)
TachikomaConfiguration.profileDirectoryName = originalProfileDirectory
try? FileManager.default.removeItem(atPath: profilePath)
}
let config = TachikomaConfiguration(loadFromEnvironment: false)
config.setAPIKey("cn-api-key", for: .minimaxCN)
try config.saveCredentials()
let savedCredentials = try String(contentsOfFile: credentialPath, encoding: .utf8)
#expect(savedCredentials.contains("MINIMAX_CN_API_KEY=cn-api-key"))
#expect(!savedCredentials.contains("MINIMAX-CN_API_KEY"))
let reloaded = TachikomaConfiguration(loadFromEnvironment: true)
#expect(reloaded.getAPIKey(for: .minimaxCN) == "cn-api-key")
}
#endif
}
@Test
func `MiniMax China availability accepts configured shared MiniMax key`() {
let config = TachikomaConfiguration(loadFromEnvironment: false)
config.setAPIKey("shared-minimax-key", for: .minimax)
#expect(config.getAPIKey(for: .minimaxCN) == "shared-minimax-key")
#expect(config.hasAPIKey(for: .minimaxCN))
}
private func withIsolatedCredentials<T: Sendable>(
_ credentials: String,
_ body: @Sendable () throws -> T,
@ -96,13 +141,17 @@ struct CredentialLoadingTests {
private func unsetOpenAIEnvironment() -> [(String, String?)] {
let keys = ["OPENAI_API_KEY", "OPENAI_ACCESS_TOKEN", "OPENAI_REFRESH_TOKEN", "OPENAI_ACCESS_EXPIRES"]
let saved = keys.map { key in
(key, getenv(key).map { String(cString: $0) })
}
let saved = self.savedEnvironment(for: keys)
keys.forEach { unsetenv($0) }
return saved
}
private func savedEnvironment(for keys: [String]) -> [(String, String?)] {
keys.map { key in
(key, getenv(key).map { String(cString: $0) })
}
}
private func restoreEnvironment(_ saved: [(String, String?)]) {
for (key, value) in saved {
if let value {

View File

@ -82,6 +82,7 @@ struct LanguageModelCoverageTests {
.groq(.llama3370b),
.grok(.grok43),
.minimax(.m27),
.minimaxCN(.m27),
.ollama(.llama33),
.lmstudio(.gptOSS20B),
.openRouter(modelId: "openrouter/alpha"),

View File

@ -72,6 +72,12 @@ struct ModelParsingTests {
#expect(LanguageModel.parse(from: "minimax/m2.7-highspeed") == .minimax(.m27Highspeed))
#expect(try ModelSelector.parseModel("minimax/m2-7-highspeed") == .minimax(.m27Highspeed))
#expect(LanguageModel.parse(from: "minimax") == .minimax(.m27))
#expect(LanguageModel.parse(from: "minimax-cn/MiniMax-M2.7") == .minimaxCN(.m27))
#expect(LanguageModel.parse(from: "minimax-cn/m2.7-highspeed") == .minimaxCN(.m27Highspeed))
#expect(try ModelSelector.parseModel("minimax-cn/m2-7") == .minimaxCN(.m27))
#expect(try ModelSelector.parseModel("minimax_cn/m2.7") == .minimaxCN(.m27))
#expect(LanguageModel.parse(from: "minimaxi/m2.7") == .minimaxCN(.m27))
#expect(LanguageModel.parse(from: "minimax-cn") == .minimaxCN(.m27))
}
@Test
@ -142,6 +148,22 @@ struct ModelParsingTests {
}
}
@Test
func `ProviderParser accepts MiniMax China provider aliases`() {
if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) {
for provider in ["minimax-cn", "minimax_cn", "minimaxi"] {
let model = ProviderParser.determineDefaultModel(
from: "\(provider)/m2.7",
hasOpenAI: false,
hasAnthropic: false,
hasMiniMax: true,
)
#expect(model == .minimaxCN(.m27))
}
}
}
@Test
func `ModelSelector rejects legacy OpenAI before Ollama fallback`() throws {
if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) {

View File

@ -51,6 +51,7 @@ enum ProviderTests {
#expect(Provider.mistral.identifier == "mistral")
#expect(Provider.google.identifier == "google")
#expect(Provider.minimax.identifier == "minimax")
#expect(Provider.minimaxCN.identifier == "minimax-cn")
#expect(Provider.ollama.identifier == "ollama")
#expect(Provider.azureOpenAI.identifier == "azure-openai")
}
@ -70,6 +71,7 @@ enum ProviderTests {
#expect(Provider.mistral.displayName == "Mistral")
#expect(Provider.google.displayName == "Google")
#expect(Provider.minimax.displayName == "MiniMax")
#expect(Provider.minimaxCN.displayName == "MiniMax China")
#expect(Provider.ollama.displayName == "Ollama")
#expect(Provider.azureOpenAI.displayName == "Azure OpenAI")
#expect(Provider.custom("test").displayName == "Test")
@ -84,6 +86,7 @@ enum ProviderTests {
#expect(Provider.mistral.environmentVariable == "MISTRAL_API_KEY")
#expect(Provider.google.environmentVariable == "GEMINI_API_KEY")
#expect(Provider.minimax.environmentVariable == "MINIMAX_API_KEY")
#expect(Provider.minimaxCN.environmentVariable == "MINIMAX_CN_API_KEY")
#expect(Provider.ollama.environmentVariable == "OLLAMA_API_KEY")
#expect(Provider.azureOpenAI.environmentVariable == "AZURE_OPENAI_API_KEY")
#expect(Provider.custom("test").environmentVariable.isEmpty)
@ -93,6 +96,7 @@ enum ProviderTests {
func `Alternative environment variables`() {
#expect(Provider.grok.alternativeEnvironmentVariables == ["XAI_API_KEY", "GROK_API_KEY"])
#expect(Provider.google.alternativeEnvironmentVariables == ["GOOGLE_API_KEY"])
#expect(Provider.minimaxCN.alternativeEnvironmentVariables == ["MINIMAX_API_KEY"])
#expect(Provider.openai.alternativeEnvironmentVariables.isEmpty)
#expect(Provider.anthropic.alternativeEnvironmentVariables.isEmpty)
#expect(Provider.azureOpenAI.alternativeEnvironmentVariables == [
@ -110,6 +114,7 @@ enum ProviderTests {
#expect(Provider.mistral.defaultBaseURL == "https://api.mistral.ai/v1")
#expect(Provider.google.defaultBaseURL == "https://generativelanguage.googleapis.com/v1beta")
#expect(Provider.minimax.defaultBaseURL == "https://api.minimax.io/anthropic")
#expect(Provider.minimaxCN.defaultBaseURL == "https://api.minimaxi.com/anthropic")
#expect(Provider.ollama.defaultBaseURL == "http://localhost:11434")
#expect(Provider.azureOpenAI.defaultBaseURL == nil)
#expect(Provider.custom("test").defaultBaseURL == nil)
@ -124,6 +129,7 @@ enum ProviderTests {
#expect(Provider.mistral.requiresAPIKey == true)
#expect(Provider.google.requiresAPIKey == true)
#expect(Provider.minimax.requiresAPIKey == true)
#expect(Provider.minimaxCN.requiresAPIKey == true)
#expect(Provider.ollama.requiresAPIKey == false) // Ollama typically doesn't require API key
#expect(Provider.azureOpenAI.requiresAPIKey == true)
#expect(Provider.custom("test").requiresAPIKey == true) // Assume custom providers need keys
@ -140,6 +146,8 @@ enum ProviderTests {
#expect(Provider.from(identifier: "mistral") == .mistral)
#expect(Provider.from(identifier: "google") == .google)
#expect(Provider.from(identifier: "minimax") == .minimax)
#expect(Provider.from(identifier: "minimax-cn") == .minimaxCN)
#expect(Provider.from(identifier: "minimaxi") == .minimaxCN)
#expect(Provider.from(identifier: "ollama") == .ollama)
#expect(Provider.from(identifier: "azure-openai") == .azureOpenAI)
}
@ -179,6 +187,7 @@ enum ProviderTests {
.mistral,
.google,
.minimax,
.minimaxCN,
.ollama,
.azureOpenAI,
]
@ -201,6 +210,18 @@ enum ProviderTests {
#expect(provider.alternativeEnvironmentVariables == ["XAI_API_KEY", "GROK_API_KEY"])
}
@Test
func `MiniMax China falls back to MiniMax environment variable`() async {
let resolved = await withTemporaryEnvironment([
"MINIMAX_CN_API_KEY": nil,
"MINIMAX_API_KEY": "shared-minimax-key",
]) {
Provider.minimaxCN.loadAPIKeyFromEnvironment()
}
#expect(resolved == "shared-minimax-key")
}
@Test
func `Custom providers don't have environment variables`() {
let customProvider = Provider.custom("test")

View File

@ -449,6 +449,43 @@ struct ProviderEndToEndTests {
}
}
@Test
func `MiniMax China provider uses China endpoint and bearer auth`() async throws {
try await NetworkMocking.withMockedNetwork { request in
#expect(request.url?.host == "api.minimaxi.com")
self.expectPath(request, endsWith: "/messages")
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer live-minimax-cn")
#expect(request.value(forHTTPHeaderField: "x-api-key") == nil)
return NetworkMocking.jsonResponse(for: request, data: Self.anthropicPayload(text: "MiniMax China ok"))
} operation: {
let config = Self.makeConfiguration { config in
config.setAPIKey("live-minimax-cn", for: .minimaxCN)
}
let provider = try ProviderFactory.createProvider(for: .minimaxCN(.m27), configuration: config)
let response = try await provider.generateText(request: Self.basicRequest)
#expect(response.text == "MiniMax China ok")
}
}
@Test
func `MiniMax China provider falls back to MiniMax API key`() async throws {
try await NetworkMocking.withMockedNetwork { request in
#expect(request.url?.host == "api.minimaxi.com")
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer shared-minimax")
return NetworkMocking.jsonResponse(
for: request,
data: Self.anthropicPayload(text: "MiniMax China fallback ok"),
)
} operation: {
let config = Self.makeConfiguration { config in
config.setAPIKey("shared-minimax", for: .minimax)
}
let provider = try ProviderFactory.createProvider(for: .minimaxCN(.m27), configuration: config)
let response = try await provider.generateText(request: Self.basicRequest)
#expect(response.text == "MiniMax China fallback ok")
}
}
// MARK: - Helpers
private func assertOpenAICompatibleProvider(_ model: LanguageModel, provider: Provider) async throws {