feat: add MiniMax provider support (#1406)

Add MiniMax as a new AI model provider with OpenAI-compatible API.

Supported models:
- MiniMax-M2.5 (default) - Peak Performance, Ultimate Value
- MiniMax-M2.5-highspeed - Same performance, faster and more agile

Changes:
- Add MiniMax client (mcp/minimax_client.go) with OpenAI-compatible API
- Add comprehensive unit tests (mcp/minimax_client_test.go)
- Add WithMiniMaxConfig option (mcp/options.go)
- Register MiniMax provider in trader, debate engine, backtest, and API
- Add MiniMax to frontend provider config and model icons
- Add MiniMax SVG icon

API Base URL: https://api.minimax.io/v1
This commit is contained in:
ximi
2026-03-09 23:18:51 +08:00
committed by GitHub
parent 79a21890d8
commit 8406f2f998
13 changed files with 409 additions and 0 deletions
+2
View File
@@ -832,6 +832,8 @@ func (s *Server) hydrateBacktestAIConfig(cfg *backtest.BacktestConfig) error {
provider = "google"
} else if strings.Contains(modelNameLower, "deepseek") {
provider = "deepseek"
} else if strings.Contains(modelNameLower, "minimax") {
provider = "minimax"
} else if model.CustomAPIURL != "" {
provider = "custom"
} else {
+2
View File
@@ -1683,6 +1683,7 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
{ID: "gemini", Name: "Gemini AI", Provider: "gemini", Enabled: false},
{ID: "grok", Name: "Grok AI", Provider: "grok", Enabled: false},
{ID: "kimi", Name: "Kimi AI", Provider: "kimi", Enabled: false},
{ID: "minimax", Name: "MiniMax AI", Provider: "minimax", Enabled: false},
}
c.JSON(http.StatusOK, defaultModels)
return
@@ -3252,6 +3253,7 @@ func (s *Server) handleGetSupportedModels(c *gin.Context) {
{"id": "gemini", "name": "Google Gemini", "provider": "gemini", "defaultModel": "gemini-3-pro-preview"},
{"id": "grok", "name": "Grok (xAI)", "provider": "grok", "defaultModel": "grok-3-latest"},
{"id": "kimi", "name": "Kimi (Moonshot)", "provider": "kimi", "defaultModel": "moonshot-v1-auto"},
{"id": "minimax", "name": "MiniMax", "provider": "minimax", "defaultModel": "MiniMax-M2.5"},
}
c.JSON(http.StatusOK, supportedModels)
+3
View File
@@ -625,6 +625,9 @@ func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string)
case "openai":
aiClient = mcp.NewOpenAIClient()
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
case "minimax":
aiClient = mcp.NewMiniMaxClient()
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
default:
// Use generic client
aiClient = mcp.NewClient()
+12
View File
@@ -71,6 +71,13 @@ func configureMCPClient(cfg BacktestConfig, base mcp.AIClient) (mcp.AIClient, er
oaiC := mcp.NewOpenAIClientWithOptions()
oaiC.(*mcp.OpenAIClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
return oaiC, nil
case "minimax":
if cfg.AICfg.APIKey == "" {
return nil, fmt.Errorf("minimax provider requires api key")
}
mmC := mcp.NewMiniMaxClientWithOptions()
mmC.(*mcp.MiniMaxClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
return mmC, nil
case "custom":
if cfg.AICfg.BaseURL == "" || cfg.AICfg.APIKey == "" || cfg.AICfg.Model == "" {
return nil, fmt.Errorf("custom provider requires base_url, api key and model")
@@ -125,6 +132,11 @@ func cloneBaseClient(base mcp.AIClient) *mcp.Client {
cp := *c.Client
return &cp
}
case *mcp.MiniMaxClient:
if c != nil && c.Client != nil {
cp := *c.Client
return &cp
}
}
// Fall back to a new default client
return mcp.NewClient().(*mcp.Client)
+2
View File
@@ -97,6 +97,8 @@ func (e *DebateEngine) InitializeClients(participants []*store.DebateParticipant
client = mcp.NewGrokClient()
case "kimi":
client = mcp.NewKimiClient()
case "minimax":
client = mcp.NewMiniMaxClient()
default:
client = mcp.New()
}
+83
View File
@@ -0,0 +1,83 @@
package mcp
import (
"net/http"
)
const (
ProviderMiniMax = "minimax"
DefaultMiniMaxBaseURL = "https://api.minimax.io/v1"
DefaultMiniMaxModel = "MiniMax-M2.5"
)
type MiniMaxClient struct {
*Client
}
// NewMiniMaxClient creates MiniMax client (backward compatible)
func NewMiniMaxClient() AIClient {
return NewMiniMaxClientWithOptions()
}
// NewMiniMaxClientWithOptions creates MiniMax client (supports options pattern)
//
// Usage examples:
//
// // Basic usage
// client := mcp.NewMiniMaxClientWithOptions()
//
// // Custom configuration
// client := mcp.NewMiniMaxClientWithOptions(
// mcp.WithAPIKey("sk-xxx"),
// mcp.WithLogger(customLogger),
// mcp.WithTimeout(60*time.Second),
// )
func NewMiniMaxClientWithOptions(opts ...ClientOption) AIClient {
// 1. Create MiniMax preset options
minimaxOpts := []ClientOption{
WithProvider(ProviderMiniMax),
WithModel(DefaultMiniMaxModel),
WithBaseURL(DefaultMiniMaxBaseURL),
}
// 2. Merge user options (user options have higher priority)
allOpts := append(minimaxOpts, opts...)
// 3. Create base client
baseClient := NewClient(allOpts...).(*Client)
// 4. Create MiniMax client
minimaxClient := &MiniMaxClient{
Client: baseClient,
}
// 5. Set hooks to point to MiniMaxClient (implement dynamic dispatch)
baseClient.hooks = minimaxClient
return minimaxClient
}
func (c *MiniMaxClient) SetAPIKey(apiKey string, customURL string, customModel string) {
c.APIKey = apiKey
if len(apiKey) > 8 {
c.logger.Infof("🔧 [MCP] MiniMax API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:])
}
if customURL != "" {
c.BaseURL = customURL
c.logger.Infof("🔧 [MCP] MiniMax using custom BaseURL: %s", customURL)
} else {
c.logger.Infof("🔧 [MCP] MiniMax using default BaseURL: %s", c.BaseURL)
}
if customModel != "" {
c.Model = customModel
c.logger.Infof("🔧 [MCP] MiniMax using custom Model: %s", customModel)
} else {
c.logger.Infof("🔧 [MCP] MiniMax using default Model: %s", c.Model)
}
}
// MiniMax uses standard OpenAI-compatible API with Bearer auth
func (c *MiniMaxClient) setAuthHeader(reqHeaders http.Header) {
c.Client.setAuthHeader(reqHeaders)
}
+272
View File
@@ -0,0 +1,272 @@
package mcp
import (
"testing"
"time"
)
// ============================================================
// Test MiniMaxClient Creation and Configuration
// ============================================================
func TestNewMiniMaxClient_Default(t *testing.T) {
client := NewMiniMaxClient()
if client == nil {
t.Fatal("client should not be nil")
}
// Type assertion check
mmClient, ok := client.(*MiniMaxClient)
if !ok {
t.Fatal("client should be *MiniMaxClient")
}
// Verify default values
if mmClient.Provider != ProviderMiniMax {
t.Errorf("Provider should be '%s', got '%s'", ProviderMiniMax, mmClient.Provider)
}
if mmClient.BaseURL != DefaultMiniMaxBaseURL {
t.Errorf("BaseURL should be '%s', got '%s'", DefaultMiniMaxBaseURL, mmClient.BaseURL)
}
if mmClient.Model != DefaultMiniMaxModel {
t.Errorf("Model should be '%s', got '%s'", DefaultMiniMaxModel, mmClient.Model)
}
if mmClient.logger == nil {
t.Error("logger should not be nil")
}
if mmClient.httpClient == nil {
t.Error("httpClient should not be nil")
}
}
func TestNewMiniMaxClientWithOptions(t *testing.T) {
mockLogger := NewMockLogger()
customModel := "MiniMax-M2.5-highspeed"
customAPIKey := "sk-custom-key"
client := NewMiniMaxClientWithOptions(
WithLogger(mockLogger),
WithModel(customModel),
WithAPIKey(customAPIKey),
WithMaxTokens(4000),
)
mmClient := client.(*MiniMaxClient)
// Verify custom options are applied
if mmClient.logger != mockLogger {
t.Error("logger should be set from option")
}
if mmClient.Model != customModel {
t.Error("Model should be set from option")
}
if mmClient.APIKey != customAPIKey {
t.Error("APIKey should be set from option")
}
if mmClient.MaxTokens != 4000 {
t.Error("MaxTokens should be 4000")
}
// Verify MiniMax default values are retained
if mmClient.Provider != ProviderMiniMax {
t.Errorf("Provider should still be '%s'", ProviderMiniMax)
}
if mmClient.BaseURL != DefaultMiniMaxBaseURL {
t.Errorf("BaseURL should still be '%s'", DefaultMiniMaxBaseURL)
}
}
// ============================================================
// Test SetAPIKey
// ============================================================
func TestMiniMaxClient_SetAPIKey(t *testing.T) {
mockLogger := NewMockLogger()
client := NewMiniMaxClientWithOptions(
WithLogger(mockLogger),
)
mmClient := client.(*MiniMaxClient)
// Test setting API Key (default URL and Model)
mmClient.SetAPIKey("sk-test-key-12345678", "", "")
if mmClient.APIKey != "sk-test-key-12345678" {
t.Errorf("APIKey should be 'sk-test-key-12345678', got '%s'", mmClient.APIKey)
}
// Verify logging
logs := mockLogger.GetLogsByLevel("INFO")
if len(logs) == 0 {
t.Error("should have logged API key setting")
}
// Verify BaseURL and Model remain default
if mmClient.BaseURL != DefaultMiniMaxBaseURL {
t.Error("BaseURL should remain default")
}
if mmClient.Model != DefaultMiniMaxModel {
t.Error("Model should remain default")
}
}
func TestMiniMaxClient_SetAPIKey_WithCustomURL(t *testing.T) {
mockLogger := NewMockLogger()
client := NewMiniMaxClientWithOptions(
WithLogger(mockLogger),
)
mmClient := client.(*MiniMaxClient)
customURL := "https://api.minimaxi.com/v1"
mmClient.SetAPIKey("sk-test-key-12345678", customURL, "")
if mmClient.BaseURL != customURL {
t.Errorf("BaseURL should be '%s', got '%s'", customURL, mmClient.BaseURL)
}
// Verify logging
logs := mockLogger.GetLogsByLevel("INFO")
hasCustomURLLog := false
for _, log := range logs {
if log.Format == "🔧 [MCP] MiniMax using custom BaseURL: %s" {
hasCustomURLLog = true
break
}
}
if !hasCustomURLLog {
t.Error("should have logged custom BaseURL")
}
}
func TestMiniMaxClient_SetAPIKey_WithCustomModel(t *testing.T) {
mockLogger := NewMockLogger()
client := NewMiniMaxClientWithOptions(
WithLogger(mockLogger),
)
mmClient := client.(*MiniMaxClient)
customModel := "MiniMax-M2.5-highspeed"
mmClient.SetAPIKey("sk-test-key-12345678", "", customModel)
if mmClient.Model != customModel {
t.Errorf("Model should be '%s', got '%s'", customModel, mmClient.Model)
}
// Verify logging
logs := mockLogger.GetLogsByLevel("INFO")
hasCustomModelLog := false
for _, log := range logs {
if log.Format == "🔧 [MCP] MiniMax using custom Model: %s" {
hasCustomModelLog = true
break
}
}
if !hasCustomModelLog {
t.Error("should have logged custom Model")
}
}
// ============================================================
// Test Integration Features
// ============================================================
func TestMiniMaxClient_CallWithMessages_Success(t *testing.T) {
mockHTTP := NewMockHTTPClient()
mockHTTP.SetSuccessResponse("MiniMax AI response")
mockLogger := NewMockLogger()
client := NewMiniMaxClientWithOptions(
WithHTTPClient(mockHTTP.ToHTTPClient()),
WithLogger(mockLogger),
WithAPIKey("sk-test-key"),
)
result, err := client.CallWithMessages("system prompt", "user prompt")
if err != nil {
t.Fatalf("should not error: %v", err)
}
if result != "MiniMax AI response" {
t.Errorf("expected 'MiniMax AI response', got '%s'", result)
}
// Verify request
requests := mockHTTP.GetRequests()
if len(requests) != 1 {
t.Fatalf("expected 1 request, got %d", len(requests))
}
req := requests[0]
// Verify URL
expectedURL := DefaultMiniMaxBaseURL + "/chat/completions"
if req.URL.String() != expectedURL {
t.Errorf("expected URL '%s', got '%s'", expectedURL, req.URL.String())
}
// Verify Authorization header
authHeader := req.Header.Get("Authorization")
if authHeader != "Bearer sk-test-key" {
t.Errorf("expected 'Bearer sk-test-key', got '%s'", authHeader)
}
// Verify Content-Type
if req.Header.Get("Content-Type") != "application/json" {
t.Error("Content-Type should be application/json")
}
}
func TestMiniMaxClient_Timeout(t *testing.T) {
client := NewMiniMaxClientWithOptions(
WithTimeout(30 * time.Second),
)
mmClient := client.(*MiniMaxClient)
if mmClient.httpClient.Timeout != 30*time.Second {
t.Errorf("expected timeout 30s, got %v", mmClient.httpClient.Timeout)
}
// Test SetTimeout
client.SetTimeout(60 * time.Second)
if mmClient.httpClient.Timeout != 60*time.Second {
t.Errorf("expected timeout 60s after SetTimeout, got %v", mmClient.httpClient.Timeout)
}
}
// ============================================================
// Test hooks Mechanism
// ============================================================
func TestMiniMaxClient_HooksIntegration(t *testing.T) {
client := NewMiniMaxClientWithOptions()
mmClient := client.(*MiniMaxClient)
// Verify hooks point to mmClient itself (implements polymorphism)
if mmClient.hooks != mmClient {
t.Error("hooks should point to mmClient for polymorphism")
}
// Verify buildUrl uses MiniMax configuration
url := mmClient.buildUrl()
expectedURL := DefaultMiniMaxBaseURL + "/chat/completions"
if url != expectedURL {
t.Errorf("expected URL '%s', got '%s'", expectedURL, url)
}
}
+14
View File
@@ -160,3 +160,17 @@ func WithQwenConfig(apiKey string) ClientOption {
c.Model = DefaultQwenModel
}
}
// WithMiniMaxConfig sets MiniMax configuration
//
// Usage example:
//
// client := mcp.NewClient(mcp.WithMiniMaxConfig("sk-xxx"))
func WithMiniMaxConfig(apiKey string) ClientOption {
return func(c *Config) {
c.Provider = ProviderMiniMax
c.APIKey = apiKey
c.BaseURL = DefaultMiniMaxBaseURL
c.Model = DefaultMiniMaxModel
}
}
+5
View File
@@ -201,6 +201,11 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName)
logger.Infof("🤖 [%s] Using OpenAI", config.Name)
case "minimax":
mcpClient = mcp.NewMiniMaxClient()
mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName)
logger.Infof("🤖 [%s] Using MiniMax AI", config.Name)
case "qwen":
mcpClient = mcp.NewQwenClient()
apiKey := config.QwenKey
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<rect width="24" height="24" rx="4" fill="#E45735"/>
<text x="12" y="16" text-anchor="middle" font-family="Arial, sans-serif" font-weight="bold" font-size="10" fill="white">M</text>
</svg>

After

Width:  |  Height:  |  Size: 266 B

+5
View File
@@ -96,6 +96,11 @@ const AI_PROVIDER_CONFIG: Record<string, {
apiUrl: 'https://platform.moonshot.ai/console/api-keys',
apiName: 'Moonshot',
},
minimax: {
defaultModel: 'MiniMax-M2.5',
apiUrl: 'https://platform.minimax.io',
apiName: 'MiniMax',
},
}
interface AITradersPageProps {
+4
View File
@@ -13,6 +13,7 @@ const MODEL_COLORS: Record<string, string> = {
gemini: '#4285F4',
grok: '#000000',
openai: '#10A37F',
minimax: '#E45735',
}
// 获取AI模型图标的函数
@@ -44,6 +45,9 @@ export const getModelIcon = (modelType: string, props: IconProps = {}) => {
case 'openai':
iconPath = '/icons/openai.svg'
break
case 'minimax':
iconPath = '/icons/minimax.svg'
break
default:
return null
}
+1
View File
@@ -104,6 +104,7 @@ function AIAvatar({ name, size = 24 }: { name: string; size?: number }) {
kimi: { bg: 'bg-purple-500', text: 'text-white', letter: 'K' },
qwen: { bg: 'bg-indigo-500', text: 'text-white', letter: 'Q' },
openai: { bg: 'bg-emerald-600', text: 'text-white', letter: 'O' },
minimax: { bg: 'bg-red-500', text: 'text-white', letter: 'M' },
gpt: { bg: 'bg-emerald-600', text: 'text-white', letter: 'O' },
}
const lower = name.toLowerCase()