diff --git a/api/service/hub_adaptor/openai/adaptor.go b/api/service/hub_adaptor/openai/adaptor.go index bef42a9..67738a5 100644 --- a/api/service/hub_adaptor/openai/adaptor.go +++ b/api/service/hub_adaptor/openai/adaptor.go @@ -17,7 +17,6 @@ import ( "github.com/gin-gonic/gin" "github.com/songquanpeng/one-api/common/logger" "github.com/songquanpeng/one-api/relay/adaptor/doubao" - "github.com/songquanpeng/one-api/relay/adaptor/minimax" "github.com/songquanpeng/one-api/relay/adaptor/novita" "github.com/songquanpeng/one-api/relay/channeltype" "github.com/songquanpeng/one-api/relay/meta" @@ -57,7 +56,12 @@ func (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) { requestURL = fmt.Sprintf("/openai/deployments/%s/%s", model_, task) return GetFullRequestURL(meta.BaseURL, requestURL, meta.ChannelType), nil case channeltype.Minimax: - return minimax.GetRequestURL(meta) + // Use standard OpenAI-compatible endpoint. + // MiniMax's new API at api.minimax.io/v1 supports the standard + // /v1/chat/completions format for both M2.x and legacy abab models. + // The upstream one-api adaptor used the deprecated /v1/text/chatcompletion_v2 + // endpoint which is no longer recommended. + return GetFullRequestURL(meta.BaseURL, meta.RequestURLPath, meta.ChannelType), nil case channeltype.Doubao: return doubao.GetRequestURL(meta) case channeltype.Novita: diff --git a/api/service/hub_adaptor/openai/adaptor_minimax_test.go b/api/service/hub_adaptor/openai/adaptor_minimax_test.go new file mode 100644 index 0000000..f14198f --- /dev/null +++ b/api/service/hub_adaptor/openai/adaptor_minimax_test.go @@ -0,0 +1,97 @@ +package openai + +import ( + "testing" + + "github.com/songquanpeng/one-api/relay/channeltype" + "github.com/songquanpeng/one-api/relay/meta" +) + +func TestGetRequestURL_Minimax(t *testing.T) { + adaptor := &Adaptor{} + + tests := []struct { + name string + baseURL string + requestPath string + model string + expected string + }{ + { + name: "M2.7 model with standard base URL", + baseURL: "https://api.minimax.io/v1", + requestPath: "/v1/chat/completions", + model: "MiniMax-M2.7", + expected: "https://api.minimax.io/v1/v1/chat/completions", + }, + { + name: "M2.7-highspeed with standard base URL", + baseURL: "https://api.minimax.io", + requestPath: "/v1/chat/completions", + model: "MiniMax-M2.7-highspeed", + expected: "https://api.minimax.io/v1/chat/completions", + }, + { + name: "Legacy abab model with standard base URL", + baseURL: "https://api.minimax.io", + requestPath: "/v1/chat/completions", + model: "abab6.5-chat", + expected: "https://api.minimax.io/v1/chat/completions", + }, + { + name: "China base URL", + baseURL: "https://api.minimaxi.com", + requestPath: "/v1/chat/completions", + model: "MiniMax-M2.7", + expected: "https://api.minimaxi.com/v1/chat/completions", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := &meta.Meta{ + ChannelType: channeltype.Minimax, + BaseURL: tt.baseURL, + RequestURLPath: tt.requestPath, + ActualModelName: tt.model, + } + adaptor.Init(m) + + url, err := adaptor.GetRequestURL(m) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if url != tt.expected { + t.Errorf("expected URL %q, got %q", tt.expected, url) + } + }) + } +} + +func TestGetRequestURL_MinimaxUsesOpenAICompatFormat(t *testing.T) { + // Verify MiniMax no longer uses the deprecated /v1/text/chatcompletion_v2 endpoint + adaptor := &Adaptor{} + m := &meta.Meta{ + ChannelType: channeltype.Minimax, + BaseURL: "https://api.minimax.io", + RequestURLPath: "/v1/chat/completions", + ActualModelName: "MiniMax-M2.7", + } + adaptor.Init(m) + + url, err := adaptor.GetRequestURL(m) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should NOT contain the deprecated endpoint + if url == "https://api.minimax.io/v1/text/chatcompletion_v2" { + t.Error("MiniMax should use OpenAI-compatible /v1/chat/completions, not deprecated /v1/text/chatcompletion_v2") + } + + // Should contain the standard OpenAI path + expected := "https://api.minimax.io/v1/chat/completions" + if url != expected { + t.Errorf("expected %q, got %q", expected, url) + } +} diff --git a/api/service/hub_adaptor/openai/compatible.go b/api/service/hub_adaptor/openai/compatible.go index 15b4dcc..c4609df 100644 --- a/api/service/hub_adaptor/openai/compatible.go +++ b/api/service/hub_adaptor/openai/compatible.go @@ -7,7 +7,6 @@ import ( "github.com/songquanpeng/one-api/relay/adaptor/doubao" "github.com/songquanpeng/one-api/relay/adaptor/groq" "github.com/songquanpeng/one-api/relay/adaptor/lingyiwanwu" - "github.com/songquanpeng/one-api/relay/adaptor/minimax" "github.com/songquanpeng/one-api/relay/adaptor/mistral" "github.com/songquanpeng/one-api/relay/adaptor/moonshot" "github.com/songquanpeng/one-api/relay/adaptor/novita" @@ -18,6 +17,22 @@ import ( "github.com/songquanpeng/one-api/relay/channeltype" ) +// MiniMaxModelList contains the current MiniMax model IDs. +// The upstream one-api dependency (v0.6.10) only includes legacy abab* models +// which use the deprecated /v1/text/chatcompletion_v2 endpoint. +// MiniMax now provides an OpenAI-compatible API at api.minimax.io/v1 with +// the M2.7 series models. We override the model list here to reflect the +// latest available models while keeping backward compatibility with abab. +var MiniMaxModelList = []string{ + "MiniMax-M2.7", + "MiniMax-M2.7-highspeed", + "abab6.5-chat", + "abab6.5s-chat", + "abab6-chat", + "abab5.5-chat", + "abab5.5s-chat", +} + var CompatibleChannels = []int{ channeltype.Azure, channeltype.AI360, @@ -47,7 +62,7 @@ func GetCompatibleChannelMeta(channelType int) (string, []string) { case channeltype.Baichuan: return "baichuan", baichuan.ModelList case channeltype.Minimax: - return "minimax", minimax.ModelList + return "minimax", MiniMaxModelList case channeltype.Mistral: return "mistralai", mistral.ModelList case channeltype.Groq: diff --git a/api/service/hub_adaptor/openai/compatible_test.go b/api/service/hub_adaptor/openai/compatible_test.go new file mode 100644 index 0000000..9b2a838 --- /dev/null +++ b/api/service/hub_adaptor/openai/compatible_test.go @@ -0,0 +1,101 @@ +package openai + +import ( + "testing" + + "github.com/songquanpeng/one-api/relay/channeltype" +) + +func TestMiniMaxModelList(t *testing.T) { + // Verify MiniMaxModelList contains the latest M2.7 models + expectedModels := map[string]bool{ + "MiniMax-M2.7": false, + "MiniMax-M2.7-highspeed": false, + } + + for _, model := range MiniMaxModelList { + if _, ok := expectedModels[model]; ok { + expectedModels[model] = true + } + } + + for model, found := range expectedModels { + if !found { + t.Errorf("MiniMaxModelList missing required model: %s", model) + } + } +} + +func TestMiniMaxModelListBackwardCompat(t *testing.T) { + // Verify legacy abab models are still present for backward compatibility + legacyModels := []string{"abab6.5-chat", "abab5.5-chat"} + modelSet := make(map[string]bool) + for _, m := range MiniMaxModelList { + modelSet[m] = true + } + + for _, model := range legacyModels { + if !modelSet[model] { + t.Errorf("MiniMaxModelList missing legacy model %s (backward compatibility)", model) + } + } +} + +func TestGetCompatibleChannelMeta_Minimax(t *testing.T) { + name, models := GetCompatibleChannelMeta(channeltype.Minimax) + + if name != "minimax" { + t.Errorf("expected channel name 'minimax', got '%s'", name) + } + + if len(models) == 0 { + t.Error("expected non-empty model list for minimax channel") + } + + // Verify the returned list matches our local override, not the upstream one-api list + hasM27 := false + for _, m := range models { + if m == "MiniMax-M2.7" { + hasM27 = true + break + } + } + if !hasM27 { + t.Error("minimax channel meta should return MiniMax-M2.7 model") + } +} + +func TestGetCompatibleChannelMeta_OtherChannels(t *testing.T) { + // Verify other channels still return correct metadata + tests := []struct { + channelType int + expectedName string + }{ + {channeltype.Azure, "azure"}, + {channeltype.DeepSeek, "deepseek"}, + {channeltype.Groq, "groq"}, + } + + for _, tt := range tests { + name, models := GetCompatibleChannelMeta(tt.channelType) + if name != tt.expectedName { + t.Errorf("channel type %d: expected name '%s', got '%s'", tt.channelType, tt.expectedName, name) + } + if len(models) == 0 { + t.Errorf("channel type %d: expected non-empty model list", tt.channelType) + } + } +} + +func TestCompatibleChannelsIncludesMinimax(t *testing.T) { + found := false + for _, ch := range CompatibleChannels { + if ch == channeltype.Minimax { + found = true + break + } + } + if !found { + t.Error("CompatibleChannels should include channeltype.Minimax") + } +} diff --git a/api/service/hub_adaptor/openai/minimax_integration_test.go b/api/service/hub_adaptor/openai/minimax_integration_test.go new file mode 100644 index 0000000..8b3160e --- /dev/null +++ b/api/service/hub_adaptor/openai/minimax_integration_test.go @@ -0,0 +1,140 @@ +package openai + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "os" + "testing" +) + +// TestMiniMaxAPIIntegration verifies that MiniMax's OpenAI-compatible API +// works correctly with the M2.7 model. This test requires MINIMAX_API_KEY +// to be set and is skipped otherwise. +func TestMiniMaxAPIIntegration(t *testing.T) { + apiKey := os.Getenv("MINIMAX_API_KEY") + if apiKey == "" { + t.Skip("MINIMAX_API_KEY not set, skipping integration test") + } + + baseURL := os.Getenv("MINIMAX_BASE_URL") + if baseURL == "" { + baseURL = "https://api.minimax.io" + } + + // Build a standard OpenAI-compatible chat completion request + reqBody := map[string]interface{}{ + "model": "MiniMax-M2.7", + "messages": []map[string]string{ + {"role": "user", "content": "Say 'test passed' and nothing else."}, + }, + "max_tokens": 20, + } + + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + t.Fatalf("failed to marshal request: %v", err) + } + + req, err := http.NewRequest("POST", baseURL+"/v1/chat/completions", bytes.NewReader(bodyBytes)) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", resp.StatusCode, string(respBody)) + } + + var result map[string]interface{} + if err := json.Unmarshal(respBody, &result); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + // Verify we got choices with content + choices, ok := result["choices"].([]interface{}) + if !ok || len(choices) == 0 { + t.Fatalf("expected non-empty choices, got: %v", result) + } + + choice := choices[0].(map[string]interface{}) + message := choice["message"].(map[string]interface{}) + content := message["content"].(string) + if content == "" { + t.Error("expected non-empty content in response") + } + + t.Logf("MiniMax M2.7 response: %s", content) +} + +// TestMiniMaxAPIStreamingIntegration verifies streaming works with the +// OpenAI-compatible endpoint. +func TestMiniMaxAPIStreamingIntegration(t *testing.T) { + apiKey := os.Getenv("MINIMAX_API_KEY") + if apiKey == "" { + t.Skip("MINIMAX_API_KEY not set, skipping integration test") + } + + baseURL := os.Getenv("MINIMAX_BASE_URL") + if baseURL == "" { + baseURL = "https://api.minimax.io" + } + + reqBody := map[string]interface{}{ + "model": "MiniMax-M2.7", + "messages": []map[string]string{ + {"role": "user", "content": "Count from 1 to 3."}, + }, + "max_tokens": 50, + "stream": true, + } + + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + t.Fatalf("failed to marshal request: %v", err) + } + + req, err := http.NewRequest("POST", baseURL+"/v1/chat/completions", bytes.NewReader(bodyBytes)) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("expected status 200, got %d: %s", resp.StatusCode, string(body)) + } + + // Read the streaming response and count SSE data lines + body, _ := io.ReadAll(resp.Body) + lines := bytes.Split(body, []byte("\n")) + dataLines := 0 + for _, line := range lines { + if bytes.HasPrefix(line, []byte("data:")) { + dataLines++ + } + } + + if dataLines < 2 { + t.Errorf("expected multiple streaming data lines, got %d", dataLines) + } + + t.Logf("MiniMax M2.7 streaming: received %d data lines", dataLines) +}