diff --git a/env.example b/env.example index 60c50dce..e2e21f40 100644 --- a/env.example +++ b/env.example @@ -3,6 +3,9 @@ OPENAI_API_KEY=sk-.... ANTHROPIC_API_KEY=sk-ant-.... GEMINI_API_KEY=AIza.... MISTRAL_API_KEY=.... +MINIMAX_API_KEY=.... +MINIMAX_BASE_URL=https://api.minimax.io/v1 +# Mainland China endpoint: https://api.minimaxi.com/v1 # UI access control (HTTP Basic Auth) — for the Linux/server deployment. # Protects the dashboard UI and the /api/* admin endpoints (mappings, dashboard, diff --git a/src/backend/config/config.go b/src/backend/config/config.go index f92fa02f..65d6e535 100644 --- a/src/backend/config/config.go +++ b/src/backend/config/config.go @@ -55,6 +55,7 @@ type ProvidersConfig struct { AnthropicProviderConfig ProviderConfig `json:"anthropic_provider_config"` GeminiProviderConfig ProviderConfig `json:"gemini_provider_config"` MistralProviderConfig ProviderConfig `json:"mistral_provider_config"` + MiniMaxProviderConfig ProviderConfig `json:"minimax_provider_config"` CustomProviderConfig ProviderConfig `json:"custom_provider_config"` } @@ -184,6 +185,9 @@ func (c *Config) ValidateConfig() error { if err := validateProviderConfig(c.Providers.MistralProviderConfig, "Mistral"); err != nil { errs = append(errs, err.Error()) } + if err := validateProviderConfig(c.Providers.MiniMaxProviderConfig, "MiniMax"); err != nil { + errs = append(errs, err.Error()) + } if err := validateProviderConfig(c.Providers.CustomProviderConfig, "Custom"); err != nil { errs = append(errs, err.Error()) } @@ -287,6 +291,10 @@ func DefaultConfig() *Config { APIDomain: providers.ProviderAPIDomainMistral, AdditionalHeaders: map[string]string{}, } + defaultMiniMaxProviderConfig := ProviderConfig{ + APIDomain: providers.ProviderAPIDomainMiniMax, + AdditionalHeaders: map[string]string{}, + } defaultCustomProviderConfig := ProviderConfig{ APIDomain: providers.ProviderAPIDomainCustom, AdditionalHeaders: map[string]string{}, @@ -305,6 +313,7 @@ func DefaultConfig() *Config { AnthropicProviderConfig: defaultAnthropicProviderConfig, GeminiProviderConfig: defaultGeminiProviderConfig, MistralProviderConfig: defaultMistralProviderConfig, + MiniMaxProviderConfig: defaultMiniMaxProviderConfig, CustomProviderConfig: defaultCustomProviderConfig, }, ProxyPort: DefaultForwardProxyPort, @@ -348,6 +357,7 @@ func (pc ProvidersConfig) GetInterceptDomains() []string { interceptDomain(pc.OpenAIProviderConfig.APIDomain), interceptDomain(pc.GeminiProviderConfig.APIDomain), interceptDomain(pc.MistralProviderConfig.APIDomain), + interceptDomain(pc.MiniMaxProviderConfig.APIDomain), interceptDomain(pc.CustomProviderConfig.APIDomain), } } diff --git a/src/backend/config/config_test.go b/src/backend/config/config_test.go index a120f986..c57a4e44 100644 --- a/src/backend/config/config_test.go +++ b/src/backend/config/config_test.go @@ -5,6 +5,8 @@ import ( "reflect" "strings" "testing" + + "github.com/dataiku/kiji-proxy/src/backend/providers" ) func TestValidatePort(t *testing.T) { @@ -362,6 +364,25 @@ func TestDefaultConfig_Detectors(t *testing.T) { } } +func TestDefaultConfig_MiniMaxProvider(t *testing.T) { + cfg := DefaultConfig() + if got := cfg.Providers.MiniMaxProviderConfig.APIDomain; got != providers.ProviderAPIDomainMiniMax { + t.Errorf("MiniMax APIDomain = %q, want %q", got, providers.ProviderAPIDomainMiniMax) + } + + wantDomain := "api.minimax.io" + found := false + for _, domain := range cfg.Providers.GetInterceptDomains() { + if domain == wantDomain { + found = true + break + } + } + if !found { + t.Errorf("GetInterceptDomains() does not contain %q", wantDomain) + } +} + // An absent "detectors" key must not clobber the default, while an explicit value // overrides it. This mirrors how config files are decoded over DefaultConfig. func TestConfig_DetectorsDecode(t *testing.T) { diff --git a/src/backend/main.go b/src/backend/main.go index ebcbd99e..37e9a594 100644 --- a/src/backend/main.go +++ b/src/backend/main.go @@ -318,6 +318,17 @@ func loadApplicationConfig(cfg *config.Config) { log.Printf("Warning: CUSTOM_API_KEY is empty or not set") } + // Override MiniMax provider config with environment variables + if minimaxURL := os.Getenv("MINIMAX_BASE_URL"); minimaxURL != "" { + cfg.Providers.MiniMaxProviderConfig.APIDomain = minimaxURL + } + if minimaxApiKey := os.Getenv("MINIMAX_API_KEY"); minimaxApiKey != "" { + cfg.Providers.MiniMaxProviderConfig.APIKey = minimaxApiKey + log.Printf("Loaded MINIMAX_API_KEY from environment (length: %d)", len(minimaxApiKey)) + } else { + log.Printf("Warning: MINIMAX_API_KEY is empty or not set") + } + if variant := os.Getenv("MODEL_VARIANT"); variant != "" { cfg.ModelVariant = variant log.Printf("Loaded MODEL_VARIANT from environment: %s", variant) diff --git a/src/backend/main_test.go b/src/backend/main_test.go index 6e2aa038..d3a8a9cd 100644 --- a/src/backend/main_test.go +++ b/src/backend/main_test.go @@ -97,3 +97,18 @@ func TestLoadApplicationConfigUnixSocket(t *testing.T) { t.Errorf("UnixSocketPath = %q, want %q", cfg.UnixSocketPath, "/tmp/kiji-proxy.sock") } } + +func TestLoadApplicationConfigMiniMax(t *testing.T) { + t.Setenv("MINIMAX_API_KEY", "test-minimax-key") + t.Setenv("MINIMAX_BASE_URL", "https://api.minimaxi.com/v1") + + cfg := config.DefaultConfig() + loadApplicationConfig(cfg) + + if got := cfg.Providers.MiniMaxProviderConfig.APIKey; got != "test-minimax-key" { + t.Errorf("MiniMax APIKey = %q, want %q", got, "test-minimax-key") + } + if got := cfg.Providers.MiniMaxProviderConfig.APIDomain; got != "https://api.minimaxi.com/v1" { + t.Errorf("MiniMax APIDomain = %q, want %q", got, "https://api.minimaxi.com/v1") + } +} diff --git a/src/backend/providers/minimax.go b/src/backend/providers/minimax.go new file mode 100644 index 00000000..b04641c3 --- /dev/null +++ b/src/backend/providers/minimax.go @@ -0,0 +1,43 @@ +package providers + +import ( + "net/http" + "strings" +) + +const ( + ProviderTypeMiniMax ProviderType = "minimax" + ProviderAPIDomainMiniMax string = "api.minimax.io/v1" + ProviderNameMiniMax string = "MiniMax" +) + +// MiniMaxProvider uses the OpenAI-compatible chat completions API shape. +type MiniMaxProvider struct { + *OpenAIProvider +} + +func NewMiniMaxProvider(apiDomain string, apiKey string, additionalHeaders map[string]string) *MiniMaxProvider { + return &MiniMaxProvider{ + OpenAIProvider: NewOpenAIProvider(apiDomain, apiKey, additionalHeaders), + } +} + +func (p *MiniMaxProvider) GetName() string { + return ProviderNameMiniMax +} + +func (p *MiniMaxProvider) GetType() ProviderType { + return ProviderTypeMiniMax +} + +func (p *MiniMaxProvider) SetAuthHeaders(req *http.Request) { + if apiKey := req.Header.Get("Authorization"); apiKey != "" { + return + } + + if strings.TrimSpace(p.apiKey) == "" { + return + } + + req.Header.Set("Authorization", "Bearer "+p.apiKey) +} diff --git a/src/backend/providers/minimax_test.go b/src/backend/providers/minimax_test.go new file mode 100644 index 00000000..86d4cc9b --- /dev/null +++ b/src/backend/providers/minimax_test.go @@ -0,0 +1,158 @@ +package providers + +import ( + "context" + "net/http" + "testing" +) + +func TestMiniMaxProvider_GetName(t *testing.T) { + p := NewMiniMaxProvider("api.minimax.io/v1", "key", nil) + if got := p.GetName(); got != ProviderNameMiniMax { + t.Errorf("GetName() = %q, want %q", got, ProviderNameMiniMax) + } +} + +func TestMiniMaxProvider_GetType(t *testing.T) { + p := NewMiniMaxProvider("api.minimax.io/v1", "key", nil) + if got := p.GetType(); got != ProviderTypeMiniMax { + t.Errorf("GetType() = %q, want %q", got, ProviderTypeMiniMax) + } +} + +func TestMiniMaxProvider_GetBaseURL(t *testing.T) { + tests := []struct { + name string + apiDomain string + useHttps bool + want string + }{ + {"full URL", "https://api.minimax.io/v1", true, "https://api.minimax.io/v1"}, + {"bare domain with path", "api.minimax.io/v1", true, "https://api.minimax.io/v1"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := NewMiniMaxProvider(tt.apiDomain, "key", nil) + if got := p.GetBaseURL(tt.useHttps); got != tt.want { + t.Errorf("GetBaseURL(%v) = %q, want %q", tt.useHttps, got, tt.want) + } + }) + } +} + +func TestMiniMaxProvider_SetAuthHeaders(t *testing.T) { + t.Run("sets Authorization header", func(t *testing.T) { + p := NewMiniMaxProvider("api.minimax.io/v1", "mm-test-key", nil) + req, _ := http.NewRequestWithContext(context.Background(), "POST", "https://api.minimax.io/v1/chat/completions", nil) + p.SetAuthHeaders(req) + if got := req.Header.Get("Authorization"); got != "Bearer mm-test-key" { + t.Errorf("Authorization = %q, want %q", got, "Bearer mm-test-key") + } + }) + + t.Run("does not override existing Authorization", func(t *testing.T) { + p := NewMiniMaxProvider("api.minimax.io/v1", "mm-test-key", nil) + req, _ := http.NewRequestWithContext(context.Background(), "POST", "https://api.minimax.io/v1/chat/completions", nil) + req.Header.Set("Authorization", "Bearer existing") + p.SetAuthHeaders(req) + if got := req.Header.Get("Authorization"); got != "Bearer existing" { + t.Errorf("Authorization = %q, want %q", got, "Bearer existing") + } + }) + + t.Run("empty key does not set header", func(t *testing.T) { + p := NewMiniMaxProvider("api.minimax.io/v1", "", nil) + req, _ := http.NewRequestWithContext(context.Background(), "POST", "https://api.minimax.io/v1/chat/completions", nil) + p.SetAuthHeaders(req) + if got := req.Header.Get("Authorization"); got != "" { + t.Errorf("Authorization should be empty, got %q", got) + } + }) +} + +func TestMiniMaxProvider_ExtractRequestText(t *testing.T) { + p := NewMiniMaxProvider("api.minimax.io/v1", "key", nil) + + data := makeOpenAIRequest([]map[string]interface{}{ + {"role": "user", "content": "Hello MiniMax"}, + }) + got, err := p.ExtractRequestText(data) + if err != nil { + t.Fatalf("ExtractRequestText() error = %v", err) + } + if got != "Hello MiniMax\n" { + t.Errorf("ExtractRequestText() = %q, want %q", got, "Hello MiniMax\n") + } +} + +func TestMiniMaxProvider_ExtractMultimodalRequestText(t *testing.T) { + p := NewMiniMaxProvider("api.minimax.io/v1", "key", nil) + data := map[string]interface{}{ + "model": "MiniMax-M3", + "messages": []interface{}{ + map[string]interface{}{ + "role": "user", + "content": []interface{}{ + map[string]interface{}{"type": "text", "text": "Hello MiniMax"}, + map[string]interface{}{"type": "image_url", "image_url": map[string]interface{}{"url": "https://example.com/image.png"}}, + map[string]interface{}{"type": "video_url", "video_url": map[string]interface{}{"url": "https://example.com/video.mp4"}}, + }, + }, + }, + } + + got, err := p.ExtractRequestText(data) + if err != nil { + t.Fatalf("ExtractRequestText() error = %v", err) + } + if got != "Hello MiniMax\n" { + t.Errorf("ExtractRequestText() = %q, want %q", got, "Hello MiniMax\n") + } +} + +func TestMiniMaxProvider_CreateMaskedMultimodalRequest(t *testing.T) { + p := NewMiniMaxProvider("api.minimax.io/v1", "key", nil) + imagePart := map[string]interface{}{ + "type": "image_url", + "image_url": map[string]interface{}{"url": "https://example.com/image.png"}, + } + textPart := map[string]interface{}{"type": "text", "text": "Hello John Doe"} + data := map[string]interface{}{ + "model": "MiniMax-M3", + "messages": []interface{}{ + map[string]interface{}{ + "role": "user", + "content": []interface{}{textPart, imagePart}, + }, + }, + } + + mapping, entities, err := p.CreateMaskedRequest(data, replaceMaskPII) + if err != nil { + t.Fatalf("CreateMaskedRequest() error = %v", err) + } + if textPart["text"] != "Hello Jane Smith" { + t.Errorf("text content = %q, want %q", textPart["text"], "Hello Jane Smith") + } + if len(mapping) == 0 || entities == nil || len(*entities) == 0 { + t.Fatal("expected masked mapping and entities") + } + if imagePart["image_url"].(map[string]interface{})["url"] != "https://example.com/image.png" { + t.Error("image content should remain unchanged") + } +} + +func TestMiniMaxProvider_ExtractResponseText(t *testing.T) { + p := NewMiniMaxProvider("api.minimax.io/v1", "key", nil) + + data := makeOpenAIResponse([]map[string]interface{}{ + {"message": map[string]interface{}{"role": "assistant", "content": "Hello from MiniMax"}}, + }) + got, err := p.ExtractResponseText(data) + if err != nil { + t.Fatalf("ExtractResponseText() error = %v", err) + } + if got != "Hello from MiniMax\n" { + t.Errorf("ExtractResponseText() = %q, want %q", got, "Hello from MiniMax\n") + } +} diff --git a/src/backend/providers/openai.go b/src/backend/providers/openai.go index 0042cf3c..73320794 100644 --- a/src/backend/providers/openai.go +++ b/src/backend/providers/openai.go @@ -252,6 +252,23 @@ func isResponsesAPIResponse(data map[string]interface{}) bool { return false } +func appendChatContentText(result *strings.Builder, content interface{}) { + switch content := content.(type) { + case string: + result.WriteString(content + "\n") + case []interface{}: + for _, part := range content { + partMap, ok := part.(map[string]interface{}) + if !ok { + continue + } + if text, ok := partMap["text"].(string); ok { + result.WriteString(text + "\n") + } + } + } +} + func (p *OpenAIProvider) ExtractRequestText(data map[string]interface{}) (string, error) { if isResponsesAPIRequest(data) { return extractResponsesRequestText(data) @@ -268,9 +285,7 @@ func (p *OpenAIProvider) ExtractRequestText(data map[string]interface{}) (string if !ok { continue } - if content, ok := msgMap["content"].(string); ok { - result.WriteString(content + "\n") - } + appendChatContentText(&result, msgMap["content"]) } return result.String(), nil } @@ -384,6 +399,12 @@ func (p *OpenAIProvider) CreateMaskedRequest(maskedRequest map[string]interface{ maskedToOriginal := make(map[string]string) var entities []pii.Entity + mergeMask := func(m map[string]string, ents []pii.Entity) { + entities = append(entities, ents...) + for k, v := range m { + maskedToOriginal[k] = v + } + } messages, ok := maskedRequest["messages"].([]interface{}) if !ok { @@ -395,19 +416,29 @@ func (p *OpenAIProvider) CreateMaskedRequest(maskedRequest map[string]interface{ if !ok { continue } - content, ok := msgMap["content"].(string) + content, ok := msgMap["content"] if !ok { continue } - - // Mask PII in this message's content and update message content with masked text - maskedText, _maskedToOriginal, _entities := maskPIIInText(content, "[MaskedRequest]") - msgMap["content"] = maskedText - - // Collect entities and mappings - entities = append(entities, _entities...) - for k, v := range _maskedToOriginal { - maskedToOriginal[k] = v + switch content := content.(type) { + case string: + masked, m, ents := maskPIIInText(content, "[MaskedRequest]") + msgMap["content"] = masked + mergeMask(m, ents) + case []interface{}: + for _, part := range content { + partMap, ok := part.(map[string]interface{}) + if !ok { + continue + } + text, ok := partMap["text"].(string) + if !ok { + continue + } + masked, m, ents := maskPIIInText(text, "[MaskedRequest]") + partMap["text"] = masked + mergeMask(m, ents) + } } } diff --git a/src/backend/providers/provider.go b/src/backend/providers/provider.go index fb5890d2..0759a718 100644 --- a/src/backend/providers/provider.go +++ b/src/backend/providers/provider.go @@ -60,14 +60,15 @@ type Provider interface { // `defaultProviders` struct sets the default provider to use when there is a subpath clash, // e.g. OpenAI and Mistral use the same '/v1/chat/completions' subpath. type defaultProviders struct { - OpenAISubpath ProviderType // only "openai" or "mistral" + OpenAISubpath ProviderType // provider selected for the shared chat completions subpath } func NewDefaultProviders(defaultOpenAIProvider ProviderType) (*defaultProviders, error) { - if defaultOpenAIProvider == ProviderTypeOpenAI || defaultOpenAIProvider == ProviderTypeMistral { + switch defaultOpenAIProvider { + case ProviderTypeOpenAI, ProviderTypeMistral, ProviderTypeMiniMax: return &defaultProviders{OpenAISubpath: defaultOpenAIProvider}, nil - } else { - return nil, fmt.Errorf("defaultOpenAIProvider must be 'openai' or 'mistral'") + default: + return nil, fmt.Errorf("unsupported default provider for shared chat completions subpath: %q", defaultOpenAIProvider) } } @@ -78,6 +79,7 @@ type Providers struct { AnthropicProvider *AnthropicProvider GeminiProvider *GeminiProvider MistralProvider *MistralProvider + MiniMaxProvider *MiniMaxProvider CustomProvider *CustomProvider } @@ -118,6 +120,8 @@ func (p *Providers) GetProviderFromPath(host string, path string, body *[]byte, provider = p.GeminiProvider case ProviderTypeMistral: provider = p.MistralProvider + case ProviderTypeMiniMax: + provider = p.MiniMaxProvider case ProviderTypeCustom: provider = p.CustomProvider default: @@ -148,6 +152,8 @@ func (p *Providers) GetProviderFromPath(host string, path string, body *[]byte, provider = p.OpenAIProvider case ProviderTypeMistral: provider = p.MistralProvider + case ProviderTypeMiniMax: + provider = p.MiniMaxProvider } case path == ProviderSubpathOpenAIResp: provider = p.OpenAIProvider @@ -183,6 +189,8 @@ func (p *Providers) GetProviderFromHost(host string, logPrefix string) (*Provide provider = p.GeminiProvider case p.MistralProvider != nil && providerHostMatches(host, p.MistralProvider.apiDomain): provider = p.MistralProvider + case p.MiniMaxProvider != nil && providerHostMatches(host, p.MiniMaxProvider.apiDomain): + provider = p.MiniMaxProvider case p.CustomProvider != nil && providerHostMatches(host, p.CustomProvider.apiDomain): provider = p.CustomProvider default: diff --git a/src/backend/providers/providers_test.go b/src/backend/providers/providers_test.go index 47567af6..42d32aeb 100644 --- a/src/backend/providers/providers_test.go +++ b/src/backend/providers/providers_test.go @@ -1356,6 +1356,7 @@ func TestNewDefaultProviders(t *testing.T) { }{ {"openai valid", ProviderTypeOpenAI, false, ProviderTypeOpenAI}, {"mistral valid", ProviderTypeMistral, false, ProviderTypeMistral}, + {"minimax valid", ProviderTypeMiniMax, false, ProviderTypeMiniMax}, {"invalid provider", ProviderType("invalid"), true, ""}, } @@ -1381,6 +1382,7 @@ func newTestProviders(defaultOpenAI ProviderType) *Providers { AnthropicProvider: NewAnthropicProvider("api.anthropic.com", "sk-ant", nil), GeminiProvider: NewGeminiProvider("generativelanguage.googleapis.com", "AIza", nil), MistralProvider: NewMistralProvider("api.mistral.ai", "sk-mistral", nil), + MiniMaxProvider: NewMiniMaxProvider("api.minimax.io/v1", "sk-minimax", nil), CustomProvider: NewCustomProvider("custom.example.com", "sk-custom", nil), } } @@ -1408,6 +1410,13 @@ func TestProviders_GetProviderFromPath(t *testing.T) { defaultOAI: ProviderTypeMistral, wantProvider: "Mistral", }, + { + name: "MiniMax from subpath when default", + path: "/v1/chat/completions", + body: `{"model":"MiniMax-M3","messages":[]}`, + defaultOAI: ProviderTypeMiniMax, + wantProvider: "MiniMax", + }, { name: "Anthropic from subpath", path: "/v1/messages", @@ -1457,6 +1466,20 @@ func TestProviders_GetProviderFromPath(t *testing.T) { defaultOAI: ProviderTypeOpenAI, wantProvider: "Mistral", }, + { + name: "provider field MiniMax-M3", + path: "/v1/chat/completions", + body: `{"provider":"minimax","model":"MiniMax-M3","messages":[]}`, + defaultOAI: ProviderTypeOpenAI, + wantProvider: "MiniMax", + }, + { + name: "provider field MiniMax-M2.7", + path: "/v1/chat/completions", + body: `{"provider":"minimax","model":"MiniMax-M2.7","messages":[]}`, + defaultOAI: ProviderTypeOpenAI, + wantProvider: "MiniMax", + }, { name: "provider field custom", path: "/v1/chat/completions", @@ -1514,6 +1537,7 @@ func TestProviders_GetProviderFromHost(t *testing.T) { {"Anthropic host", "api.anthropic.com", "Anthropic", false}, {"Gemini host", "generativelanguage.googleapis.com", "Gemini", false}, {"Mistral host", "api.mistral.ai", "Mistral", false}, + {"MiniMax host", "api.minimax.io", "MiniMax", false}, {"Custom host", "custom.example.com", "Custom Provider", false}, {"unknown host", "unknown.example.com", "", true}, } @@ -1532,4 +1556,15 @@ func TestProviders_GetProviderFromHost(t *testing.T) { } }) } + + t.Run("MiniMax regional host", func(t *testing.T) { + providers.MiniMaxProvider = NewMiniMaxProvider("https://api.minimaxi.com/v1", "sk-minimax", nil) + provider, err := providers.GetProviderFromHost("api.minimaxi.com", "[test]") + if err != nil { + t.Fatalf("GetProviderFromHost() error = %v", err) + } + if got := (*provider).GetName(); got != ProviderNameMiniMax { + t.Errorf("GetProviderFromHost() provider = %q, want %q", got, ProviderNameMiniMax) + } + }) } diff --git a/src/backend/proxy/handler.go b/src/backend/proxy/handler.go index 5f5163e5..bed41caa 100644 --- a/src/backend/proxy/handler.go +++ b/src/backend/proxy/handler.go @@ -515,6 +515,8 @@ func providerFromModel(model string) string { return "gemini" case strings.Contains(m, "mistral"), strings.Contains(m, "mixtral"): return "mistral" + case strings.Contains(m, "abab"), strings.Contains(m, "minimax"): + return "minimax" case strings.Contains(m, "gpt"), strings.Contains(m, "davinci"), strings.HasPrefix(m, "o1"), strings.HasPrefix(m, "o3"), strings.HasPrefix(m, "o4"): return "openai" @@ -920,6 +922,11 @@ func NewHandler(cfg *config.Config) (*Handler, error) { cfg.Providers.MistralProviderConfig.APIKey, cfg.Providers.MistralProviderConfig.AdditionalHeaders, ) + miniMaxProvider := providers.NewMiniMaxProvider( + cfg.Providers.MiniMaxProviderConfig.APIDomain, + cfg.Providers.MiniMaxProviderConfig.APIKey, + cfg.Providers.MiniMaxProviderConfig.AdditionalHeaders, + ) customProvider := providers.NewCustomProvider( cfg.Providers.CustomProviderConfig.APIDomain, cfg.Providers.CustomProviderConfig.APIKey, @@ -939,6 +946,7 @@ func NewHandler(cfg *config.Config) (*Handler, error) { AnthropicProvider: anthropicProvider, GeminiProvider: geminiProvider, MistralProvider: mistralProvider, + MiniMaxProvider: miniMaxProvider, CustomProvider: customProvider, } diff --git a/src/backend/proxy/handler_test.go b/src/backend/proxy/handler_test.go index 492af14c..91bccbbd 100644 --- a/src/backend/proxy/handler_test.go +++ b/src/backend/proxy/handler_test.go @@ -209,6 +209,11 @@ func newTestHandler(t *testing.T, detector *mockDetector, upstreamServer *httpte APIKey: "sk-mistral-test", AdditionalHeaders: map[string]string{}, }, + MiniMaxProviderConfig: config.ProviderConfig{ + APIDomain: "api.minimax.io/v1", + APIKey: "sk-minimax-test", + AdditionalHeaders: map[string]string{}, + }, CustomProviderConfig: config.ProviderConfig{ APIDomain: "custom.example.com", APIKey: "sk-custom-test", @@ -244,6 +249,11 @@ func newTestHandler(t *testing.T, detector *mockDetector, upstreamServer *httpte cfg.Providers.MistralProviderConfig.APIKey, cfg.Providers.MistralProviderConfig.AdditionalHeaders, ) + miniMaxProvider := providers.NewMiniMaxProvider( + cfg.Providers.MiniMaxProviderConfig.APIDomain, + cfg.Providers.MiniMaxProviderConfig.APIKey, + cfg.Providers.MiniMaxProviderConfig.AdditionalHeaders, + ) customProvider := providers.NewCustomProvider( cfg.Providers.CustomProviderConfig.APIDomain, cfg.Providers.CustomProviderConfig.APIKey, @@ -261,6 +271,7 @@ func newTestHandler(t *testing.T, detector *mockDetector, upstreamServer *httpte AnthropicProvider: anthropicProvider, GeminiProvider: geminiProvider, MistralProvider: mistralProvider, + MiniMaxProvider: miniMaxProvider, CustomProvider: customProvider, } @@ -292,6 +303,16 @@ func newTestHandler(t *testing.T, detector *mockDetector, upstreamServer *httpte } } +func TestProviderFromModelMiniMax(t *testing.T) { + for _, model := range []string{"MiniMax-M3", "MiniMax-M2.7"} { + t.Run(model, func(t *testing.T) { + if got := providerFromModel(model); got != "minimax" { + t.Errorf("providerFromModel(%q) = %q, want %q", model, got, "minimax") + } + }) + } +} + // --- Handler Unit Tests --- func TestHandler_ReadRequestBody(t *testing.T) { diff --git a/src/frontend/src/components/privacy-proxy-ui.tsx b/src/frontend/src/components/privacy-proxy-ui.tsx index 497b96a5..18950a3c 100644 --- a/src/frontend/src/components/privacy-proxy-ui.tsx +++ b/src/frontend/src/components/privacy-proxy-ui.tsx @@ -319,6 +319,7 @@ export default function PrivacyProxyUI({ "anthropic", "gemini", "mistral", + "minimax", "custom", ] as ProviderType[] ).map((provider) => ( diff --git a/src/frontend/src/components/settings/ProvidersSection.tsx b/src/frontend/src/components/settings/ProvidersSection.tsx index 016ffe3e..1eb807ea 100644 --- a/src/frontend/src/components/settings/ProvidersSection.tsx +++ b/src/frontend/src/components/settings/ProvidersSection.tsx @@ -17,6 +17,7 @@ import { // Providers that support a user-configurable custom endpoint URL. const PROVIDERS_WITH_CUSTOM_ENDPOINT: ReadonlySet = new Set([ + "minimax", "custom", ]); @@ -59,6 +60,16 @@ const PROVIDER_INFO: Record< placeholder: "...", helpLink: "https://console.mistral.ai/api-keys", }, + minimax: { + name: "MiniMax", + defaultModel: "MiniMax-M3", + placeholder: "...", + helpLink: "https://platform.minimax.io/user-center/basic-information/interface-key", + baseUrlPlaceholder: "https://api.minimax.io/v1", + modelHelpText: "Supported model IDs include MiniMax-M3 and MiniMax-M2.7.", + endpointHelpText: + "Use https://api.minimaxi.com/v1 for the mainland China endpoint.", + }, custom: { name: "Custom Provider", defaultModel: "your-model-id", @@ -76,6 +87,7 @@ const PROVIDER_ORDER: ProviderType[] = [ "anthropic", "gemini", "mistral", + "minimax", "custom", ]; @@ -101,6 +113,7 @@ export default function ProvidersSection({ onSaved }: ProvidersSectionProps) { anthropic: { hasApiKey: false, model: "" }, gemini: { hasApiKey: false, model: "" }, mistral: { hasApiKey: false, model: "" }, + minimax: { hasApiKey: false, model: "" }, custom: { hasApiKey: false, model: "", baseUrl: "" }, }, }); @@ -118,6 +131,7 @@ export default function ProvidersSection({ onSaved }: ProvidersSectionProps) { anthropic: false, gemini: false, mistral: false, + minimax: false, custom: false, }); @@ -129,6 +143,7 @@ export default function ProvidersSection({ onSaved }: ProvidersSectionProps) { anthropic: "", gemini: "", mistral: "", + minimax: "", custom: "", }); @@ -139,6 +154,7 @@ export default function ProvidersSection({ onSaved }: ProvidersSectionProps) { anthropic: "", gemini: "", mistral: "", + minimax: "", custom: "", }); @@ -149,6 +165,7 @@ export default function ProvidersSection({ onSaved }: ProvidersSectionProps) { anthropic: "", gemini: "", mistral: "", + minimax: "", custom: "", }); @@ -166,6 +183,7 @@ export default function ProvidersSection({ onSaved }: ProvidersSectionProps) { anthropic: "", gemini: "", mistral: "", + minimax: "", custom: "", }; const baseUrls: Record = { @@ -173,6 +191,7 @@ export default function ProvidersSection({ onSaved }: ProvidersSectionProps) { anthropic: "", gemini: "", mistral: "", + minimax: "", custom: "", }; for (const provider of PROVIDER_ORDER) { @@ -188,6 +207,7 @@ export default function ProvidersSection({ onSaved }: ProvidersSectionProps) { anthropic: "", gemini: "", mistral: "", + minimax: "", custom: "", }); } catch (error) { @@ -278,6 +298,7 @@ export default function ProvidersSection({ onSaved }: ProvidersSectionProps) { anthropic: "", gemini: "", mistral: "", + minimax: "", custom: "", }); diff --git a/src/frontend/src/electron/electron-main.js b/src/frontend/src/electron/electron-main.js index 433149c8..a71f600a 100644 --- a/src/frontend/src/electron/electron-main.js +++ b/src/frontend/src/electron/electron-main.js @@ -156,6 +156,7 @@ const PROVIDER_ENV_NAMES = { anthropic: { apiKey: "ANTHROPIC_API_KEY", baseUrl: "ANTHROPIC_BASE_URL" }, gemini: { apiKey: "GEMINI_API_KEY", baseUrl: "GEMINI_BASE_URL" }, mistral: { apiKey: "MISTRAL_API_KEY", baseUrl: "MISTRAL_BASE_URL" }, + minimax: { apiKey: "MINIMAX_API_KEY", baseUrl: "MINIMAX_BASE_URL" }, custom: { apiKey: "CUSTOM_API_KEY", baseUrl: "CUSTOM_BASE_URL" }, }; @@ -1081,8 +1082,16 @@ app.on("will-quit", () => { // Migrate old single-key config format to new multi-provider format const migrateConfig = (config) => { - // If already migrated (has providers object), return as-is + // Backfill MiniMax for existing multi-provider configurations. if (config.providers) { + if (!config.providers.minimax) { + config.providers.minimax = { + apiKey: "", + encrypted: false, + model: "", + baseUrl: "", + }; + } return config; } @@ -1094,6 +1103,7 @@ const migrateConfig = (config) => { anthropic: { apiKey: "", encrypted: false, model: "" }, gemini: { apiKey: "", encrypted: false, model: "" }, mistral: { apiKey: "", encrypted: false, model: "" }, + minimax: { apiKey: "", encrypted: false, model: "", baseUrl: "" }, custom: { apiKey: "", encrypted: false, model: "", baseUrl: "" }, }; diff --git a/src/frontend/src/electron/electron.d.ts b/src/frontend/src/electron/electron.d.ts index 63dd5945..860ac605 100644 --- a/src/frontend/src/electron/electron.d.ts +++ b/src/frontend/src/electron/electron.d.ts @@ -6,6 +6,7 @@ type ProviderType = | "anthropic" | "gemini" | "mistral" + | "minimax" | "custom"; interface ProviderSettings { diff --git a/src/frontend/src/electron/ipc-handlers.js b/src/frontend/src/electron/ipc-handlers.js index b81334cc..f8555ddc 100644 --- a/src/frontend/src/electron/ipc-handlers.js +++ b/src/frontend/src/electron/ipc-handlers.js @@ -4,7 +4,14 @@ const fs = require("fs"); // Valid provider types accepted by the Go backend. // Keep in sync with src/backend/main.go loadApplicationConfig(). -const VALID_PROVIDERS = ["openai", "anthropic", "gemini", "mistral", "custom"]; +const VALID_PROVIDERS = [ + "openai", + "anthropic", + "gemini", + "mistral", + "minimax", + "custom", +]; // Make a JSON request to the local Go backend. // Resolves with the parsed JSON body; rejects on network error or invalid JSON. @@ -396,6 +403,7 @@ const registerIpcHandlers = ({ anthropic: { hasApiKey: false, model: "", baseUrl: "" }, gemini: { hasApiKey: false, model: "", baseUrl: "" }, mistral: { hasApiKey: false, model: "", baseUrl: "" }, + minimax: { hasApiKey: false, model: "", baseUrl: "" }, custom: { hasApiKey: false, model: "", baseUrl: "" }, }, }; diff --git a/src/frontend/src/hooks/useElectronSettings.ts b/src/frontend/src/hooks/useElectronSettings.ts index 1e413750..0641e83d 100644 --- a/src/frontend/src/hooks/useElectronSettings.ts +++ b/src/frontend/src/hooks/useElectronSettings.ts @@ -16,6 +16,7 @@ export function useElectronSettings(callbacks: ModalCallbacks) { anthropic: { hasApiKey: false, model: "" }, gemini: { hasApiKey: false, model: "" }, mistral: { hasApiKey: false, model: "" }, + minimax: { hasApiKey: false, model: "", baseUrl: "" }, custom: { hasApiKey: false, model: "", baseUrl: "" }, }, }); diff --git a/src/frontend/src/types/provider.ts b/src/frontend/src/types/provider.ts index c80bcb0e..b48038d7 100644 --- a/src/frontend/src/types/provider.ts +++ b/src/frontend/src/types/provider.ts @@ -5,6 +5,7 @@ export type ProviderType = | "anthropic" | "gemini" | "mistral" + | "minimax" | "custom"; export interface ProviderSettings { @@ -24,6 +25,7 @@ export const DEFAULT_MODELS: Record = { anthropic: "claude-haiku-4-5", gemini: "gemini-flash-latest", mistral: "mistral-small-latest", + minimax: "MiniMax-M3", custom: "", }; @@ -33,6 +35,7 @@ export const PROVIDER_NAMES: Record = { anthropic: "Anthropic", gemini: "Gemini", mistral: "Mistral", + minimax: "MiniMax", custom: "Custom Provider", }; diff --git a/src/frontend/src/utils/providerHelpers.ts b/src/frontend/src/utils/providerHelpers.ts index 0e9a2231..0591d1b6 100644 --- a/src/frontend/src/utils/providerHelpers.ts +++ b/src/frontend/src/utils/providerHelpers.ts @@ -44,6 +44,7 @@ export function buildRequestBody( switch (provider) { case "openai": case "mistral": + case "minimax": case "custom": return { ...baseFields, @@ -94,7 +95,7 @@ export function buildHeaders( case "gemini": headers["x-goog-api-key"] = providerApiKey; break; - default: // openai, mistral, custom + default: // openai, mistral, minimax, custom headers["Authorization"] = `Bearer ${providerApiKey}`; } @@ -108,6 +109,7 @@ export function getProviderEndpoint( switch (provider) { case "openai": case "mistral": + case "minimax": case "custom": return "/v1/chat/completions"; case "anthropic": @@ -127,6 +129,7 @@ export function extractAssistantMessage( switch (provider) { case "openai": case "mistral": + case "minimax": case "custom": return data.choices?.[0]?.message?.content || "";