From 6d803e57807748aa6bc9a2abb8e47e9ddfd3e8a0 Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:34:30 +0800 Subject: [PATCH] feat: add MiniMax M3 provider --- env.example | 1 + src/backend/config/config.go | 10 ++ src/backend/main.go | 9 ++ src/backend/providers/minimax.go | 43 ++++++++ src/backend/providers/minimax_test.go | 101 ++++++++++++++++++ src/backend/providers/provider.go | 5 + src/backend/proxy/handler.go | 8 ++ .../src/components/privacy-proxy-ui.tsx | 1 + .../components/settings/ProvidersSection.tsx | 16 +++ src/frontend/src/electron/electron.d.ts | 1 + src/frontend/src/types/provider.ts | 3 + src/frontend/src/utils/providerHelpers.ts | 5 +- 12 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 src/backend/providers/minimax.go create mode 100644 src/backend/providers/minimax_test.go diff --git a/env.example b/env.example index 60c50dce..b86b5fbc 100644 --- a/env.example +++ b/env.example @@ -3,6 +3,7 @@ OPENAI_API_KEY=sk-.... ANTHROPIC_API_KEY=sk-ant-.... GEMINI_API_KEY=AIza.... MISTRAL_API_KEY=.... +MINIMAX_API_KEY=.... # 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/main.go b/src/backend/main.go index ebcbd99e..f96a012c 100644 --- a/src/backend/main.go +++ b/src/backend/main.go @@ -318,6 +318,15 @@ 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)) + } + if variant := os.Getenv("MODEL_VARIANT"); variant != "" { cfg.ModelVariant = variant log.Printf("Loaded MODEL_VARIANT from environment: %s", variant) 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..f5f4790c --- /dev/null +++ b/src/backend/providers/minimax_test.go @@ -0,0 +1,101 @@ +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_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/provider.go b/src/backend/providers/provider.go index fb5890d2..db514c42 100644 --- a/src/backend/providers/provider.go +++ b/src/backend/providers/provider.go @@ -78,6 +78,7 @@ type Providers struct { AnthropicProvider *AnthropicProvider GeminiProvider *GeminiProvider MistralProvider *MistralProvider + MiniMaxProvider *MiniMaxProvider CustomProvider *CustomProvider } @@ -118,6 +119,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: @@ -183,6 +186,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/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/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..22702d6c 100644 --- a/src/frontend/src/components/settings/ProvidersSection.tsx +++ b/src/frontend/src/components/settings/ProvidersSection.tsx @@ -59,6 +59,12 @@ const PROVIDER_INFO: Record< placeholder: "...", helpLink: "https://console.mistral.ai/api-keys", }, + minimax: { + name: "MiniMax", + defaultModel: "MiniMax-M3", + placeholder: "...", + helpLink: "https://platform.minimax.chat/user-center/basic-information/interface-key", + }, custom: { name: "Custom Provider", defaultModel: "your-model-id", @@ -76,6 +82,7 @@ const PROVIDER_ORDER: ProviderType[] = [ "anthropic", "gemini", "mistral", + "minimax", "custom", ]; @@ -101,6 +108,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 +126,7 @@ export default function ProvidersSection({ onSaved }: ProvidersSectionProps) { anthropic: false, gemini: false, mistral: false, + minimax: false, custom: false, }); @@ -129,6 +138,7 @@ export default function ProvidersSection({ onSaved }: ProvidersSectionProps) { anthropic: "", gemini: "", mistral: "", + minimax: "", custom: "", }); @@ -139,6 +149,7 @@ export default function ProvidersSection({ onSaved }: ProvidersSectionProps) { anthropic: "", gemini: "", mistral: "", + minimax: "", custom: "", }); @@ -149,6 +160,7 @@ export default function ProvidersSection({ onSaved }: ProvidersSectionProps) { anthropic: "", gemini: "", mistral: "", + minimax: "", custom: "", }); @@ -166,6 +178,7 @@ export default function ProvidersSection({ onSaved }: ProvidersSectionProps) { anthropic: "", gemini: "", mistral: "", + minimax: "", custom: "", }; const baseUrls: Record = { @@ -173,6 +186,7 @@ export default function ProvidersSection({ onSaved }: ProvidersSectionProps) { anthropic: "", gemini: "", mistral: "", + minimax: "", custom: "", }; for (const provider of PROVIDER_ORDER) { @@ -188,6 +202,7 @@ export default function ProvidersSection({ onSaved }: ProvidersSectionProps) { anthropic: "", gemini: "", mistral: "", + minimax: "", custom: "", }); } catch (error) { @@ -278,6 +293,7 @@ export default function ProvidersSection({ onSaved }: ProvidersSectionProps) { anthropic: "", gemini: "", mistral: "", + minimax: "", custom: "", }); 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/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 || "";