Skip to content

Commit c734db3

Browse files
authored
feat: add minimax image generation relay support (#4103)
1 parent a18ea3c commit c734db3

File tree

5 files changed

+363
-1
lines changed

5 files changed

+363
-1
lines changed

relay/channel/minimax/adaptor.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,10 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
7878
}
7979

8080
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
81-
return request, nil
81+
if info.RelayMode != constant.RelayModeImagesGenerations {
82+
return nil, fmt.Errorf("unsupported image relay mode: %d", info.RelayMode)
83+
}
84+
return oaiImage2MiniMaxImageRequest(request), nil
8285
}
8386

8487
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
@@ -121,6 +124,9 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
121124
if info.RelayMode == constant.RelayModeAudioSpeech {
122125
return handleTTSResponse(c, resp, info)
123126
}
127+
if info.RelayMode == constant.RelayModeImagesGenerations {
128+
return miniMaxImageHandler(c, resp, info)
129+
}
124130

125131
switch info.RelayFormat {
126132
case types.RelayFormatClaude:
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package minimax
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"strings"
8+
"testing"
9+
"time"
10+
11+
"github.com/QuantumNous/new-api/dto"
12+
relaycommon "github.com/QuantumNous/new-api/relay/common"
13+
relayconstant "github.com/QuantumNous/new-api/relay/constant"
14+
15+
"github.com/gin-gonic/gin"
16+
)
17+
18+
func TestGetRequestURLForImageGeneration(t *testing.T) {
19+
t.Parallel()
20+
21+
info := &relaycommon.RelayInfo{
22+
RelayMode: relayconstant.RelayModeImagesGenerations,
23+
ChannelMeta: &relaycommon.ChannelMeta{
24+
ChannelBaseUrl: "https://api.minimax.chat",
25+
},
26+
}
27+
28+
got, err := GetRequestURL(info)
29+
if err != nil {
30+
t.Fatalf("GetRequestURL returned error: %v", err)
31+
}
32+
33+
want := "https://api.minimax.chat/v1/image_generation"
34+
if got != want {
35+
t.Fatalf("GetRequestURL() = %q, want %q", got, want)
36+
}
37+
}
38+
39+
func TestConvertImageRequest(t *testing.T) {
40+
t.Parallel()
41+
42+
adaptor := &Adaptor{}
43+
info := &relaycommon.RelayInfo{
44+
RelayMode: relayconstant.RelayModeImagesGenerations,
45+
OriginModelName: "image-01",
46+
}
47+
request := dto.ImageRequest{
48+
Model: "image-01",
49+
Prompt: "a red fox in snowfall",
50+
Size: "1536x1024",
51+
ResponseFormat: "url",
52+
N: uintPtr(2),
53+
}
54+
55+
got, err := adaptor.ConvertImageRequest(gin.CreateTestContextOnly(httptest.NewRecorder(), gin.New()), info, request)
56+
if err != nil {
57+
t.Fatalf("ConvertImageRequest returned error: %v", err)
58+
}
59+
60+
body, err := json.Marshal(got)
61+
if err != nil {
62+
t.Fatalf("json.Marshal returned error: %v", err)
63+
}
64+
65+
var payload map[string]any
66+
if err := json.Unmarshal(body, &payload); err != nil {
67+
t.Fatalf("json.Unmarshal returned error: %v", err)
68+
}
69+
70+
if payload["model"] != "image-01" {
71+
t.Fatalf("model = %#v, want %q", payload["model"], "image-01")
72+
}
73+
if payload["prompt"] != request.Prompt {
74+
t.Fatalf("prompt = %#v, want %q", payload["prompt"], request.Prompt)
75+
}
76+
if payload["n"] != float64(2) {
77+
t.Fatalf("n = %#v, want 2", payload["n"])
78+
}
79+
if payload["aspect_ratio"] != "3:2" {
80+
t.Fatalf("aspect_ratio = %#v, want %q", payload["aspect_ratio"], "3:2")
81+
}
82+
if payload["response_format"] != "url" {
83+
t.Fatalf("response_format = %#v, want %q", payload["response_format"], "url")
84+
}
85+
}
86+
87+
func TestDoResponseForImageGeneration(t *testing.T) {
88+
t.Parallel()
89+
90+
gin.SetMode(gin.TestMode)
91+
recorder := httptest.NewRecorder()
92+
c, _ := gin.CreateTestContext(recorder)
93+
94+
info := &relaycommon.RelayInfo{
95+
RelayMode: relayconstant.RelayModeImagesGenerations,
96+
StartTime: time.Unix(1700000000, 0),
97+
}
98+
resp := &http.Response{
99+
StatusCode: http.StatusOK,
100+
Header: make(http.Header),
101+
Body: httptest.NewRecorder().Result().Body,
102+
}
103+
resp.Body = ioNopCloser(`{"data":{"image_urls":["https://example.com/minimax.png"]}}`)
104+
105+
adaptor := &Adaptor{}
106+
usage, err := adaptor.DoResponse(c, resp, info)
107+
if err != nil {
108+
t.Fatalf("DoResponse returned error: %v", err)
109+
}
110+
if usage == nil {
111+
t.Fatalf("DoResponse returned nil usage")
112+
}
113+
114+
body := recorder.Body.String()
115+
if !strings.Contains(body, `"url":"https://example.com/minimax.png"`) {
116+
t.Fatalf("response body = %s, want OpenAI image response with image URL", body)
117+
}
118+
if strings.Contains(body, `"image_urls"`) {
119+
t.Fatalf("response body = %s, should not expose raw MiniMax image_urls payload", body)
120+
}
121+
}
122+
123+
type nopReadCloser struct {
124+
*strings.Reader
125+
}
126+
127+
func (n nopReadCloser) Close() error {
128+
return nil
129+
}
130+
131+
func ioNopCloser(body string) nopReadCloser {
132+
return nopReadCloser{Reader: strings.NewReader(body)}
133+
}
134+
135+
func uintPtr(v uint) *uint {
136+
return &v
137+
}

relay/channel/minimax/constants.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ var ModelList = []string{
88
"abab6-chat",
99
"abab5.5-chat",
1010
"abab5.5s-chat",
11+
"MiniMax-M2.7",
12+
"MiniMax-M2.7-highspeed",
1113
"speech-2.5-hd-preview",
1214
"speech-2.5-turbo-preview",
1315
"speech-02-hd",
@@ -19,6 +21,8 @@ var ModelList = []string{
1921
"MiniMax-M2",
2022
"MiniMax-M2.5",
2123
"MiniMax-M2.5-highspeed",
24+
"image-01",
25+
"image-01-live",
2226
}
2327

2428
var ChannelName = "minimax"

relay/channel/minimax/image.go

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
package minimax
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"net/http"
7+
"strconv"
8+
"strings"
9+
10+
"github.com/QuantumNous/new-api/common"
11+
"github.com/QuantumNous/new-api/dto"
12+
relaycommon "github.com/QuantumNous/new-api/relay/common"
13+
"github.com/QuantumNous/new-api/service"
14+
"github.com/QuantumNous/new-api/types"
15+
16+
"github.com/gin-gonic/gin"
17+
)
18+
19+
type MiniMaxImageRequest struct {
20+
Model string `json:"model"`
21+
Prompt string `json:"prompt"`
22+
AspectRatio string `json:"aspect_ratio,omitempty"`
23+
ResponseFormat string `json:"response_format,omitempty"`
24+
N int `json:"n,omitempty"`
25+
PromptOptimizer *bool `json:"prompt_optimizer,omitempty"`
26+
AigcWatermark *bool `json:"aigc_watermark,omitempty"`
27+
}
28+
29+
type MiniMaxImageResponse struct {
30+
ID string `json:"id"`
31+
Data struct {
32+
ImageURLs []string `json:"image_urls"`
33+
ImageBase64 []string `json:"image_base64"`
34+
} `json:"data"`
35+
Metadata map[string]any `json:"metadata"`
36+
BaseResp struct {
37+
StatusCode int `json:"status_code"`
38+
StatusMsg string `json:"status_msg"`
39+
} `json:"base_resp"`
40+
}
41+
42+
func oaiImage2MiniMaxImageRequest(request dto.ImageRequest) MiniMaxImageRequest {
43+
responseFormat := normalizeMiniMaxResponseFormat(request.ResponseFormat)
44+
minimaxRequest := MiniMaxImageRequest{
45+
Model: request.Model,
46+
Prompt: request.Prompt,
47+
ResponseFormat: responseFormat,
48+
N: 1,
49+
AigcWatermark: request.Watermark,
50+
}
51+
52+
if request.Model == "" {
53+
minimaxRequest.Model = "image-01"
54+
}
55+
if request.N != nil && *request.N > 0 {
56+
minimaxRequest.N = int(*request.N)
57+
}
58+
if aspectRatio := aspectRatioFromImageRequest(request); aspectRatio != "" {
59+
minimaxRequest.AspectRatio = aspectRatio
60+
}
61+
if raw, ok := request.Extra["prompt_optimizer"]; ok {
62+
var promptOptimizer bool
63+
if err := common.Unmarshal(raw, &promptOptimizer); err == nil {
64+
minimaxRequest.PromptOptimizer = &promptOptimizer
65+
}
66+
}
67+
68+
return minimaxRequest
69+
}
70+
71+
func aspectRatioFromImageRequest(request dto.ImageRequest) string {
72+
if raw, ok := request.Extra["aspect_ratio"]; ok {
73+
var aspectRatio string
74+
if err := common.Unmarshal(raw, &aspectRatio); err == nil && aspectRatio != "" {
75+
return aspectRatio
76+
}
77+
}
78+
79+
switch request.Size {
80+
case "1024x1024":
81+
return "1:1"
82+
case "1792x1024":
83+
return "16:9"
84+
case "1024x1792":
85+
return "9:16"
86+
case "1536x1024", "1248x832":
87+
return "3:2"
88+
case "1024x1536", "832x1248":
89+
return "2:3"
90+
case "1152x864":
91+
return "4:3"
92+
case "864x1152":
93+
return "3:4"
94+
case "1344x576":
95+
return "21:9"
96+
}
97+
98+
width, height, ok := parseImageSize(request.Size)
99+
if !ok {
100+
return ""
101+
}
102+
ratio := reduceAspectRatio(width, height)
103+
switch ratio {
104+
case "1:1", "16:9", "4:3", "3:2", "2:3", "3:4", "9:16", "21:9":
105+
return ratio
106+
default:
107+
return ""
108+
}
109+
}
110+
111+
func parseImageSize(size string) (int, int, bool) {
112+
parts := strings.Split(size, "x")
113+
if len(parts) != 2 {
114+
return 0, 0, false
115+
}
116+
width, err := strconv.Atoi(parts[0])
117+
if err != nil {
118+
return 0, 0, false
119+
}
120+
height, err := strconv.Atoi(parts[1])
121+
if err != nil {
122+
return 0, 0, false
123+
}
124+
if width <= 0 || height <= 0 {
125+
return 0, 0, false
126+
}
127+
return width, height, true
128+
}
129+
130+
func reduceAspectRatio(width, height int) string {
131+
divisor := gcd(width, height)
132+
return fmt.Sprintf("%d:%d", width/divisor, height/divisor)
133+
}
134+
135+
func gcd(a, b int) int {
136+
for b != 0 {
137+
a, b = b, a%b
138+
}
139+
if a == 0 {
140+
return 1
141+
}
142+
return a
143+
}
144+
145+
func normalizeMiniMaxResponseFormat(responseFormat string) string {
146+
switch strings.ToLower(responseFormat) {
147+
case "", "url":
148+
return "url"
149+
case "b64_json", "base64":
150+
return "base64"
151+
default:
152+
return responseFormat
153+
}
154+
}
155+
156+
func responseMiniMax2OpenAIImage(response *MiniMaxImageResponse, info *relaycommon.RelayInfo) (*dto.ImageResponse, error) {
157+
imageResponse := &dto.ImageResponse{
158+
Created: info.StartTime.Unix(),
159+
}
160+
161+
for _, imageURL := range response.Data.ImageURLs {
162+
imageResponse.Data = append(imageResponse.Data, dto.ImageData{Url: imageURL})
163+
}
164+
for _, imageBase64 := range response.Data.ImageBase64 {
165+
imageResponse.Data = append(imageResponse.Data, dto.ImageData{B64Json: imageBase64})
166+
}
167+
if len(response.Metadata) > 0 {
168+
metadata, err := common.Marshal(response.Metadata)
169+
if err != nil {
170+
return nil, err
171+
}
172+
imageResponse.Metadata = metadata
173+
}
174+
175+
return imageResponse, nil
176+
}
177+
178+
func miniMaxImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.Usage, *types.NewAPIError) {
179+
responseBody, err := io.ReadAll(resp.Body)
180+
if err != nil {
181+
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
182+
}
183+
service.CloseResponseBodyGracefully(resp)
184+
185+
var minimaxResponse MiniMaxImageResponse
186+
if err := common.Unmarshal(responseBody, &minimaxResponse); err != nil {
187+
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
188+
}
189+
if minimaxResponse.BaseResp.StatusCode != 0 {
190+
return nil, types.WithOpenAIError(types.OpenAIError{
191+
Message: minimaxResponse.BaseResp.StatusMsg,
192+
Type: "minimax_image_error",
193+
Code: fmt.Sprintf("%d", minimaxResponse.BaseResp.StatusCode),
194+
}, resp.StatusCode)
195+
}
196+
197+
openAIResponse, err := responseMiniMax2OpenAIImage(&minimaxResponse, info)
198+
if err != nil {
199+
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
200+
}
201+
jsonResponse, err := common.Marshal(openAIResponse)
202+
if err != nil {
203+
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
204+
}
205+
206+
c.Writer.Header().Set("Content-Type", "application/json")
207+
c.Writer.WriteHeader(resp.StatusCode)
208+
if _, err := c.Writer.Write(jsonResponse); err != nil {
209+
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
210+
}
211+
212+
return &dto.Usage{}, nil
213+
}

0 commit comments

Comments
 (0)