From b5c4d1de2a7f788b09a5e5d7db6ccd0731c0adb1 Mon Sep 17 00:00:00 2001 From: Tai Nguyen <49914814+nvt-ak@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:35:21 +0700 Subject: [PATCH] fix bug can not create image with minimax provider (#630) --- internal/tools/create_image_minimax.go | 70 +++++----- internal/tools/create_image_minimax_test.go | 134 ++++++++++++++++++++ 2 files changed, 174 insertions(+), 30 deletions(-) create mode 100644 internal/tools/create_image_minimax_test.go diff --git a/internal/tools/create_image_minimax.go b/internal/tools/create_image_minimax.go index ab58839d..d9a52a97 100644 --- a/internal/tools/create_image_minimax.go +++ b/internal/tools/create_image_minimax.go @@ -13,42 +13,46 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/providers" ) -// callMinimaxImageGen calls the MiniMax image generation API. -// Endpoint: POST {apiBase}/image_generation -// Response: base64 image data in data.image_list[0].base64_image -// aspectRatioToMinimaxSize converts aspect_ratio (e.g. "16:9") to MiniMax size format. -// Falls back to explicit "size" param if set, otherwise uses aspect_ratio mapping. -func aspectRatioToMinimaxSize(params map[string]any) string { - // Explicit size param takes priority (from UI provider settings) +// minimaxImageAspectRatio returns the aspect_ratio string for MiniMax image_generation. +// See: https://platform.minimax.io/docs/guides/image-generation +// Legacy chain settings may still pass "size" as "WIDTH*HEIGHT"; map those to ratios. +func minimaxImageAspectRatio(params map[string]any) string { if s := GetParamString(params, "size", ""); s != "" { - return s + switch strings.ReplaceAll(s, " ", "") { + case "1280*720": + return "16:9" + case "720*1280": + return "9:16" + case "1024*768": + return "4:3" + case "768*1024": + return "3:4" + case "1024*1024": + return "1:1" + } } - ar := GetParamString(params, "aspect_ratio", "1:1") + ar := GetParamString(params, "aspect_ratio", "") switch ar { - case "16:9": - return "1280*720" - case "9:16": - return "720*1280" - case "4:3": - return "1024*768" - case "3:4": - return "768*1024" + case "1:1", "3:4", "4:3", "9:16", "16:9": + return ar + case "": + return "1:1" default: - return "1024*1024" + return "1:1" } } +// callMinimaxImageGen calls the MiniMax image generation API. +// Endpoint: POST {apiBase}/image_generation +// Response: base64 strings in data.image_base64 (per official guide). func callMinimaxImageGen(ctx context.Context, apiKey, apiBase, model, prompt string, params map[string]any) ([]byte, *providers.Usage, error) { - size := aspectRatioToMinimaxSize(params) - promptOptimizer := GetParamBool(params, "prompt_optimizer", true) + aspectRatio := minimaxImageAspectRatio(params) body := map[string]any{ - "model": model, - "prompt": prompt, - "size": size, - "num_images": 1, - "enable_base64_output": true, - "prompt_optimizer": promptOptimizer, + "model": model, + "prompt": prompt, + "aspect_ratio": aspectRatio, + "response_format": "base64", } jsonBody, err := json.Marshal(body) @@ -81,7 +85,8 @@ func callMinimaxImageGen(ctx context.Context, apiKey, apiBase, model, prompt str var minimaxResp struct { Data *struct { - ImageList []struct { + ImageBase64 []string `json:"image_base64"` + ImageList []struct { Base64Image string `json:"base64_image"` } `json:"image_list"` } `json:"data"` @@ -99,13 +104,18 @@ func callMinimaxImageGen(ctx context.Context, apiKey, apiBase, model, prompt str minimaxResp.BaseResp.StatusCode, minimaxResp.BaseResp.StatusMsg) } - if minimaxResp.Data == nil || len(minimaxResp.Data.ImageList) == 0 { + if minimaxResp.Data == nil { return nil, nil, fmt.Errorf("no image data in MiniMax response") } - b64 := minimaxResp.Data.ImageList[0].Base64Image + var b64 string + if len(minimaxResp.Data.ImageBase64) > 0 { + b64 = minimaxResp.Data.ImageBase64[0] + } else if len(minimaxResp.Data.ImageList) > 0 { + b64 = minimaxResp.Data.ImageList[0].Base64Image + } if b64 == "" { - return nil, nil, fmt.Errorf("empty base64_image in MiniMax response") + return nil, nil, fmt.Errorf("no image data in MiniMax response") } imageBytes, err := base64.StdEncoding.DecodeString(b64) diff --git a/internal/tools/create_image_minimax_test.go b/internal/tools/create_image_minimax_test.go new file mode 100644 index 00000000..0285b8e7 --- /dev/null +++ b/internal/tools/create_image_minimax_test.go @@ -0,0 +1,134 @@ +package tools + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestMinimaxImageAspectRatio(t *testing.T) { + tests := []struct { + name string + params map[string]any + want string + }{ + { + name: "explicit ratio", + params: map[string]any{"aspect_ratio": "16:9"}, + want: "16:9", + }, + { + name: "default empty", + params: map[string]any{}, + want: "1:1", + }, + { + name: "unknown ratio falls back to 1:1", + params: map[string]any{"aspect_ratio": "21:9"}, + want: "1:1", + }, + { + name: "legacy size wins over aspect_ratio", + params: map[string]any{"size": "1280*720", "aspect_ratio": "1:1"}, + want: "16:9", + }, + { + name: "legacy size 1024*1024", + params: map[string]any{"size": "1024*1024"}, + want: "1:1", + }, + { + name: "unknown size ignored uses aspect_ratio", + params: map[string]any{"size": "custom", "aspect_ratio": "9:16"}, + want: "9:16", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := minimaxImageAspectRatio(tt.params); got != tt.want { + t.Fatalf("minimaxImageAspectRatio() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestCallMinimaxImageGen_ImageBase64Response(t *testing.T) { + wantPNG := []byte{0x89, 0x50, 0x4e, 0x47} + b64 := base64.StdEncoding.EncodeToString(wantPNG) + + var gotAuth string + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/image_generation" { + t.Errorf("path = %q, want /v1/image_generation", r.URL.Path) + } + gotAuth = r.Header.Get("Authorization") + raw, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(raw, &body); err != nil { + t.Errorf("request body: %v", err) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{"image_base64": []string{b64}}, + }) + })) + defer srv.Close() + + out, usage, err := callMinimaxImageGen(context.Background(), "test-key", srv.URL+"/v1", "image-01", "a prompt", + map[string]any{"aspect_ratio": "16:9"}) + if err != nil { + t.Fatalf("callMinimaxImageGen: %v", err) + } + if usage != nil { + t.Fatalf("usage = %#v, want nil", usage) + } + if string(out) != string(wantPNG) { + t.Fatalf("decoded bytes mismatch") + } + if gotAuth != "Bearer test-key" { + t.Fatalf("Authorization = %q", gotAuth) + } + if body["model"] != "image-01" || body["prompt"] != "a prompt" || + body["aspect_ratio"] != "16:9" || body["response_format"] != "base64" { + t.Fatalf("request fields = %#v", body) + } +} + +func TestCallMinimaxImageGen_LegacyImageListFallback(t *testing.T) { + want := []byte("x") + b64 := base64.StdEncoding.EncodeToString(want) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "image_list": []map[string]any{{"base64_image": b64}}, + }, + }) + })) + defer srv.Close() + + out, _, err := callMinimaxImageGen(context.Background(), "k", srv.URL+"/v1", "m", "p", nil) + if err != nil { + t.Fatal(err) + } + if string(out) != string(want) { + t.Fatalf("got %q, want %q", out, want) + } +} + +func TestCallMinimaxImageGen_BaseRespError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "base_resp": map[string]any{"status_code": 1004, "status_msg": "bad"}, + "data": map[string]any{"image_base64": []string{}}, + }) + })) + defer srv.Close() + + _, _, err := callMinimaxImageGen(context.Background(), "k", srv.URL+"/v1", "m", "p", nil) + if err == nil { + t.Fatal("expected error") + } +}