From 8406f2f99849461a580dd7ae880c8675128aab38 Mon Sep 17 00:00:00 2001 From: ximi Date: Mon, 9 Mar 2026 23:18:51 +0800 Subject: [PATCH] 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 --- api/backtest.go | 2 + api/server.go | 2 + api/strategy.go | 3 + backtest/ai_client.go | 12 ++ debate/engine.go | 2 + mcp/minimax_client.go | 83 ++++++++ mcp/minimax_client_test.go | 272 +++++++++++++++++++++++++++ mcp/options.go | 14 ++ trader/auto_trader.go | 5 + web/public/icons/minimax.svg | 4 + web/src/components/AITradersPage.tsx | 5 + web/src/components/ModelIcons.tsx | 4 + web/src/pages/DebateArenaPage.tsx | 1 + 13 files changed, 409 insertions(+) create mode 100644 mcp/minimax_client.go create mode 100644 mcp/minimax_client_test.go create mode 100644 web/public/icons/minimax.svg diff --git a/api/backtest.go b/api/backtest.go index acdfe378..82b5eecb 100644 --- a/api/backtest.go +++ b/api/backtest.go @@ -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 { diff --git a/api/server.go b/api/server.go index fc545a54..16759092 100644 --- a/api/server.go +++ b/api/server.go @@ -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) diff --git a/api/strategy.go b/api/strategy.go index 5f724ab6..ec11f7a9 100644 --- a/api/strategy.go +++ b/api/strategy.go @@ -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() diff --git a/backtest/ai_client.go b/backtest/ai_client.go index 4eeb0855..74c34761 100644 --- a/backtest/ai_client.go +++ b/backtest/ai_client.go @@ -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) diff --git a/debate/engine.go b/debate/engine.go index 4700d2ff..9d9dffb1 100644 --- a/debate/engine.go +++ b/debate/engine.go @@ -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() } diff --git a/mcp/minimax_client.go b/mcp/minimax_client.go new file mode 100644 index 00000000..7bedb15a --- /dev/null +++ b/mcp/minimax_client.go @@ -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) +} diff --git a/mcp/minimax_client_test.go b/mcp/minimax_client_test.go new file mode 100644 index 00000000..21e45e22 --- /dev/null +++ b/mcp/minimax_client_test.go @@ -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) + } +} diff --git a/mcp/options.go b/mcp/options.go index 3e962627..2a6fb03d 100644 --- a/mcp/options.go +++ b/mcp/options.go @@ -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 + } +} diff --git a/trader/auto_trader.go b/trader/auto_trader.go index 9145e476..baedeaa6 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -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 diff --git a/web/public/icons/minimax.svg b/web/public/icons/minimax.svg new file mode 100644 index 00000000..332eb759 --- /dev/null +++ b/web/public/icons/minimax.svg @@ -0,0 +1,4 @@ + + + M + diff --git a/web/src/components/AITradersPage.tsx b/web/src/components/AITradersPage.tsx index f0de17b2..71a29fe1 100644 --- a/web/src/components/AITradersPage.tsx +++ b/web/src/components/AITradersPage.tsx @@ -96,6 +96,11 @@ const AI_PROVIDER_CONFIG: Record = { 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 } diff --git a/web/src/pages/DebateArenaPage.tsx b/web/src/pages/DebateArenaPage.tsx index 21df55ad..eda7b9bd 100644 --- a/web/src/pages/DebateArenaPage.tsx +++ b/web/src/pages/DebateArenaPage.tsx @@ -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()