Compare commits
1 Commits
main
...
codex/mini
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08e6d1aea6 |
@ -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'")
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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") ||
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -82,6 +82,7 @@ struct LanguageModelCoverageTests {
|
||||
.groq(.llama3370b),
|
||||
.grok(.grok43),
|
||||
.minimax(.m27),
|
||||
.minimaxCN(.m27),
|
||||
.ollama(.llama33),
|
||||
.lmstudio(.gptOSS20B),
|
||||
.openRouter(modelId: "openrouter/alpha"),
|
||||
|
||||
@ -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, *) {
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user