Files
goclaw/internal/tools/create_image_minimax.go
T
thotam a5a853f461 feat(tools): image reference processing and native provider support (#1251)
* feat(tools): implement image reference processing and native provider support

- Support OpenAI image edits via both Multipart form-data and JSON payloads
- Automatically append reference image descriptions to prompt under [Reference Image Roles]
- Support downloading image URLs for Gemini native image generation
- Deduplicate reference images to optimize API request size
- Add unit tests for Codex, DashScope, MiniMax, BytePlus and local/remote path resolution

* fix(tools): SSRF-guard reference-image URL downloads in create_image

downloadImageBytes fetched caller-supplied ref_images[].url with a plain
http.Client and unbounded io.ReadAll — no SSRF validation, redirect
policy, or size cap, letting the gateway dial loopback/private/metadata
hosts or read arbitrarily large responses.

- Validate the URL via security.Validate and pin the resolved IP, then
  download through security.NewSafeClient (pinned dial, no redirects).
- Cap the response with a bounded read (refImageMaxBytes, 20 MB).
- Reject non-HTTP(S) reference URLs up front (file://, data:, gopher://)
  so provider-forwarded URLs stay HTTP(S)-only; document the trust
  boundary between gateway-side fetch and provider-forwarded URLs.
- Add regression tests: blocked loopback/private/metadata, unfollowed
  redirect, oversized response, and non-http(s) scheme rejection.
2026-06-21 18:27:51 +07:00

154 lines
4.2 KiB
Go

package tools
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
// 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 != "" {
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", "")
switch ar {
case "1:1", "3:4", "4:3", "9:16", "16:9":
return ar
case "":
return "1:1"
default:
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) {
aspectRatio := minimaxImageAspectRatio(params)
body := map[string]any{
"model": model,
"prompt": prompt,
"aspect_ratio": aspectRatio,
"response_format": "base64",
}
if rawImgs, ok := params["ref_images"]; ok {
if refImgs, ok := rawImgs.([]*referenceImage); ok && len(refImgs) > 0 {
if len(refImgs) > 1 {
slog.Warn("minimax image gen: provider only supports 1 reference image, using the first one", "count", len(refImgs))
}
refImg := refImgs[0]
var refURL string
if refImg.URL != "" {
refURL = refImg.URL
} else {
refMime := refImg.MimeType
if refMime == "" {
refMime = "image/png"
}
refURL = fmt.Sprintf("data:%s;base64,%s", refMime, refImg.Base64)
}
body["subject_reference"] = []map[string]any{
{
"type": "character",
"image_file": refURL,
},
}
}
}
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, nil, fmt.Errorf("marshal request: %w", err)
}
url := strings.TrimRight(apiBase, "/") + "/image_generation"
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonBody))
if err != nil {
return nil, nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
client := &http.Client{} // timeout governed by chain context
resp, err := client.Do(req)
if err != nil {
return nil, nil, fmt.Errorf("http request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("API error %d: %s", resp.StatusCode, truncateBytes(respBody, 500))
}
var minimaxResp struct {
Data *struct {
ImageBase64 []string `json:"image_base64"`
ImageList []struct {
Base64Image string `json:"base64_image"`
} `json:"image_list"`
} `json:"data"`
BaseResp *struct {
StatusCode int `json:"status_code"`
StatusMsg string `json:"status_msg"`
} `json:"base_resp"`
}
if err := json.Unmarshal(respBody, &minimaxResp); err != nil {
return nil, nil, fmt.Errorf("parse response: %w", err)
}
if minimaxResp.BaseResp != nil && minimaxResp.BaseResp.StatusCode != 0 {
return nil, nil, fmt.Errorf("MiniMax API error %d: %s",
minimaxResp.BaseResp.StatusCode, minimaxResp.BaseResp.StatusMsg)
}
if minimaxResp.Data == nil {
return nil, nil, fmt.Errorf("no image data in MiniMax response")
}
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("no image data in MiniMax response")
}
imageBytes, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return nil, nil, fmt.Errorf("decode base64: %w", err)
}
return imageBytes, nil, nil
}