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.
This commit is contained in:
thotam authored and GitHub committed 2026-06-21 18:27:51 +07:00
1 parent 389640ae51
commit a5a853f461
9 files changed
+1465 -34

No files matched your search

+488 -1
View File
@@ -2,6 +2,14 @@ package tools
import (
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/providers"
@@ -18,7 +26,7 @@ type nativeImageProvider struct {
}
func (p *nativeImageProvider) Name() string { return p.name }
func (p *nativeImageProvider) DefaultModel() string { return p.model }
func (p *nativeImageProvider) DefaultModel() string { return p.model }
func (p *nativeImageProvider) Chat(_ context.Context, _ providers.ChatRequest) (*providers.ChatResponse, error) {
return &providers.ChatResponse{}, nil
}
@@ -256,3 +264,482 @@ func TestCreateImageTool_ThreadsImageModel(t *testing.T) {
})
}
}
func TestCreateImageTool_ResolveReferenceImage_Path(t *testing.T) {
tmpDir := t.TempDir()
refFile := filepath.Join(tmpDir, "ref.png")
pngBytes := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}
if err := os.WriteFile(refFile, pngBytes, 0644); err != nil {
t.Fatalf("failed to write temp ref file: %v", err)
}
fakeProvider := &nativeImageProvider{
name: "openai-codex",
model: "gpt-image-2",
returnData: pngBytes,
}
reg := providers.NewRegistry(nil)
reg.Register(fakeProvider)
tool := NewCreateImageTool(reg)
ctx := WithToolWorkspace(context.Background(), tmpDir)
chainJSON := []byte(`{"providers":[{"provider":"openai-codex","model":"gpt-image-2","enabled":true,"timeout":30,"max_retries":1}]}`)
settings := BuiltinToolSettings{"create_image": chainJSON}
ctx = WithBuiltinToolSettings(ctx, settings)
result := tool.Execute(ctx, map[string]any{
"prompt": "generate a picture",
"ref_images": []any{map[string]any{"path": refFile}},
})
if result.IsError {
t.Fatalf("Execute returned error: %q", result.ForLLM)
}
if fakeProvider.calledWith == nil {
t.Fatal("GenerateImage was not called")
}
if len(fakeProvider.calledWith.RefImages) != 1 {
t.Fatalf("expected 1 reference image, got %d", len(fakeProvider.calledWith.RefImages))
}
if fakeProvider.calledWith.RefImages[0].Base64 == "" {
t.Error("RefImages[0].Base64 was not populated")
}
if fakeProvider.calledWith.RefImages[0].Strength != 0.6 {
t.Errorf("RefImages[0].Strength = %f, want 0.6", fakeProvider.calledWith.RefImages[0].Strength)
}
}
func TestCreateImageTool_ResolveReferenceImage_URL(t *testing.T) {
fakeProvider := &nativeImageProvider{
name: "openai-codex",
model: "gpt-image-2",
returnData: []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a},
}
reg := providers.NewRegistry(nil)
reg.Register(fakeProvider)
tool := NewCreateImageTool(reg)
ctx := WithToolWorkspace(context.Background(), t.TempDir())
chainJSON := []byte(`{"providers":[{"provider":"openai-codex","model":"gpt-image-2","enabled":true,"timeout":30,"max_retries":1}]}`)
settings := BuiltinToolSettings{"create_image": chainJSON}
ctx = WithBuiltinToolSettings(ctx, settings)
result := tool.Execute(ctx, map[string]any{
"prompt": "generate a picture",
"ref_images": []any{map[string]any{"url": "https://example.com/ref.png"}},
})
if result.IsError {
t.Fatalf("Execute returned error: %q", result.ForLLM)
}
if fakeProvider.calledWith == nil {
t.Fatal("GenerateImage was not called")
}
if len(fakeProvider.calledWith.RefImages) != 1 {
t.Fatalf("expected 1 reference image, got %d", len(fakeProvider.calledWith.RefImages))
}
if fakeProvider.calledWith.RefImages[0].URL != "https://example.com/ref.png" {
t.Errorf("RefImageUrl = %q, want https://example.com/ref.png", fakeProvider.calledWith.RefImages[0].URL)
}
}
func TestCreateImageTool_ResolveReferenceImage_ID(t *testing.T) {
fakeProvider := &nativeImageProvider{
name: "openai-codex",
model: "gpt-image-2",
returnData: []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a},
}
reg := providers.NewRegistry(nil)
reg.Register(fakeProvider)
tool := NewCreateImageTool(reg)
ctx := WithToolWorkspace(context.Background(), t.TempDir())
// Put mock image content in context
mockImg := providers.ImageContent{
MimeType: "image/png",
Data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", // 1x1 png base64
}
ctx = WithMediaImages(ctx, []providers.ImageContent{mockImg})
chainJSON := []byte(`{"providers":[{"provider":"openai-codex","model":"gpt-image-2","enabled":true,"timeout":30,"max_retries":1}]}`)
settings := BuiltinToolSettings{"create_image": chainJSON}
ctx = WithBuiltinToolSettings(ctx, settings)
result := tool.Execute(ctx, map[string]any{
"prompt": "generate a picture",
"ref_images": []any{map[string]any{"id": "latest"}},
})
if result.IsError {
t.Fatalf("Execute returned error: %q", result.ForLLM)
}
if fakeProvider.calledWith == nil {
t.Fatal("GenerateImage was not called")
}
if len(fakeProvider.calledWith.RefImages) != 1 {
t.Fatalf("expected 1 reference image, got %d", len(fakeProvider.calledWith.RefImages))
}
if fakeProvider.calledWith.RefImages[0].Base64 != mockImg.Data {
t.Errorf("RefImageBase64 = %q, want %q", fakeProvider.calledWith.RefImages[0].Base64, mockImg.Data)
}
}
func TestCreateImageTool_MultipleReferenceImages_MixedSources(t *testing.T) {
tmpDir := t.TempDir()
refFile := filepath.Join(tmpDir, "ref.png")
pngBytes := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}
if err := os.WriteFile(refFile, pngBytes, 0644); err != nil {
t.Fatalf("failed to write temp ref file: %v", err)
}
fakeProvider := &nativeImageProvider{
name: "openai-codex",
model: "gpt-image-2",
returnData: pngBytes,
}
reg := providers.NewRegistry(nil)
reg.Register(fakeProvider)
tool := NewCreateImageTool(reg)
ctx := WithToolWorkspace(context.Background(), tmpDir)
chainJSON := []byte(`{"providers":[{"provider":"openai-codex","model":"gpt-image-2","enabled":true,"timeout":30,"max_retries":1}]}`)
settings := BuiltinToolSettings{"create_image": chainJSON}
ctx = WithBuiltinToolSettings(ctx, settings)
result := tool.Execute(ctx, map[string]any{
"prompt": "generate a picture",
"ref_images": []any{
map[string]any{
"path": refFile,
"strength": 0.9,
},
map[string]any{
"url": "https://example.com/ref2.png",
"strength": 0.4,
},
},
})
if result.IsError {
t.Fatalf("Execute returned error: %q", result.ForLLM)
}
if fakeProvider.calledWith == nil {
t.Fatal("GenerateImage was not called")
}
if len(fakeProvider.calledWith.RefImages) != 2 {
t.Fatalf("expected 2 reference images, got %d", len(fakeProvider.calledWith.RefImages))
}
// Verify image 1 (path)
if fakeProvider.calledWith.RefImages[0].Base64 == "" {
t.Error("RefImages[0].Base64 was not populated")
}
if fakeProvider.calledWith.RefImages[0].Strength != 0.9 {
t.Errorf("RefImages[0].Strength = %f, want 0.9", fakeProvider.calledWith.RefImages[0].Strength)
}
// Verify image 2 (url)
if fakeProvider.calledWith.RefImages[1].URL != "https://example.com/ref2.png" {
t.Errorf("RefImages[1].URL = %q, want https://example.com/ref2.png", fakeProvider.calledWith.RefImages[1].URL)
}
if fakeProvider.calledWith.RefImages[1].Strength != 0.4 {
t.Errorf("RefImages[1].Strength = %f, want 0.4", fakeProvider.calledWith.RefImages[1].Strength)
}
}
type fakeCreds struct {
key string
base string
}
func (c fakeCreds) APIKey() string { return c.key }
func (c fakeCreds) APIBase() string { return c.base }
func TestCreateImageTool_OpenAIEdits_JSON(t *testing.T) {
pngBytes := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52}
mockResponse := `{
"data": [
{
"b64_json": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
}
]
}`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/images/edits" {
t.Errorf("expected path /images/edits, got %s", r.URL.Path)
}
ct := r.Header.Get("Content-Type")
if !strings.HasPrefix(ct, "application/json") {
t.Errorf("expected application/json content type, got %s", ct)
}
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("failed to read body: %v", err)
}
var reqBody struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
ResponseFormat string `json:"response_format"`
Images []struct {
ImageURL string `json:"image_url"`
} `json:"images"`
}
if err := json.Unmarshal(bodyBytes, &reqBody); err != nil {
t.Fatalf("failed to unmarshal body: %v", err)
}
if reqBody.Prompt != "sunny beach" {
t.Errorf("expected prompt 'sunny beach', got %s", reqBody.Prompt)
}
if reqBody.Model != "gpt-image-2" {
t.Errorf("expected model 'gpt-image-2', got %s", reqBody.Model)
}
if reqBody.ResponseFormat != "b64_json" {
t.Errorf("expected response_format 'b64_json', got %s", reqBody.ResponseFormat)
}
if len(reqBody.Images) != 1 {
t.Fatalf("expected 1 image, got %d", len(reqBody.Images))
}
expectedURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(pngBytes)
if reqBody.Images[0].ImageURL != expectedURL {
t.Errorf("image url mismatch")
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(mockResponse))
}))
defer server.Close()
creds := fakeCreds{key: "fake-key", base: server.URL}
reg := providers.NewRegistry(nil)
tool := NewCreateImageTool(reg)
ctx := WithToolWorkspace(context.Background(), t.TempDir())
refImg := &referenceImage{
Data: pngBytes,
MimeType: "image/png",
}
params := map[string]any{
"prompt": "sunny beach",
"ref_images": []*referenceImage{refImg},
"_provider_type": "openai",
}
imageBytes, _, err := tool.callProvider(ctx, creds, "openai", "gpt-image-2", params)
if err != nil {
t.Fatalf("callProvider returned error: %v", err)
}
if len(imageBytes) == 0 {
t.Error("returned imageBytes is empty")
}
}
func TestCreateImageTool_DeduplicateReferenceImages(t *testing.T) {
fakeProvider := &nativeImageProvider{
name: "openai-codex",
model: "gpt-image-2",
returnData: []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a},
}
reg := providers.NewRegistry(nil)
reg.Register(fakeProvider)
tool := NewCreateImageTool(reg)
ctx := WithToolWorkspace(context.Background(), t.TempDir())
chainJSON := []byte(`{"providers":[{"provider":"openai-codex","model":"gpt-image-2","enabled":true,"timeout":30,"max_retries":1}]}`)
settings := BuiltinToolSettings{"create_image": chainJSON}
ctx = WithBuiltinToolSettings(ctx, settings)
// Pass duplicate parameters as sent by the user
url1 := "https://example.com/image1.png"
url2 := "https://example.com/image2.png"
result := tool.Execute(ctx, map[string]any{
"prompt": "generate a picture",
"ref_images": []any{
map[string]any{
"url": url1,
"strength": 0.8,
},
map[string]any{
"url": url2,
"strength": 0.8,
},
map[string]any{
"url": url1, // Duplicated
"strength": 0.8,
},
map[string]any{
"url": url2, // Duplicated
"strength": 0.8,
},
},
})
if result.IsError {
t.Fatalf("Execute returned error: %q", result.ForLLM)
}
if fakeProvider.calledWith == nil {
t.Fatal("GenerateImage was not called")
}
// Verify that the length is 2 instead of 4
gotLen := len(fakeProvider.calledWith.RefImages)
if gotLen != 2 {
t.Fatalf("expected 2 unique reference images, got %d (duplicated URL error!)", gotLen)
}
// Verify that the priority order of ref_images is preserved (strength = 0.8)
if fakeProvider.calledWith.RefImages[0].Strength != 0.8 {
t.Errorf("RefImages[0].Strength = %f, want 0.8", fakeProvider.calledWith.RefImages[0].Strength)
}
if fakeProvider.calledWith.RefImages[1].Strength != 0.8 {
t.Errorf("RefImages[1].Strength = %f, want 0.8", fakeProvider.calledWith.RefImages[1].Strength)
}
}
func TestCreateImageTool_OpenAIEdits_JSON_MultipleImages(t *testing.T) {
pngBytes1 := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52}
pngBytes2 := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x53}
mockResponse := `{
"data": [
{
"b64_json": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
}
]
}`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/images/edits" {
t.Errorf("expected path /images/edits, got %s", r.URL.Path)
}
ct := r.Header.Get("Content-Type")
if !strings.HasPrefix(ct, "application/json") {
t.Errorf("expected application/json content type, got %s", ct)
}
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("failed to read body: %v", err)
}
var reqBody struct {
Prompt string `json:"prompt"`
Images []struct {
ImageURL string `json:"image_url"`
} `json:"images"`
}
if err := json.Unmarshal(bodyBytes, &reqBody); err != nil {
t.Fatalf("failed to unmarshal body: %v", err)
}
if reqBody.Prompt != "two captains" {
t.Errorf("expected prompt 'two captains', got %s", reqBody.Prompt)
}
if len(reqBody.Images) != 2 {
t.Fatalf("expected 2 images, got %d", len(reqBody.Images))
}
expectedURL1 := "data:image/png;base64," + base64.StdEncoding.EncodeToString(pngBytes1)
expectedURL2 := "data:image/png;base64," + base64.StdEncoding.EncodeToString(pngBytes2)
if reqBody.Images[0].ImageURL != expectedURL1 || reqBody.Images[1].ImageURL != expectedURL2 {
t.Error("images mismatch")
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(mockResponse))
}))
defer server.Close()
creds := fakeCreds{key: "fake-key", base: server.URL}
reg := providers.NewRegistry(nil)
tool := NewCreateImageTool(reg)
ctx := WithToolWorkspace(context.Background(), t.TempDir())
refImg1 := &referenceImage{Data: pngBytes1, MimeType: "image/png"}
refImg2 := &referenceImage{Data: pngBytes2, MimeType: "image/png"}
params := map[string]any{
"prompt": "two captains",
"ref_images": []*referenceImage{refImg1, refImg2},
"_provider_type": "openai",
}
imageBytes, _, err := tool.callProvider(ctx, creds, "openai", "gpt-image-2", params)
if err != nil {
t.Fatalf("callProvider returned error: %v", err)
}
if len(imageBytes) == 0 {
t.Error("returned imageBytes is empty")
}
}
func TestCreateImageTool_OpenAIEdits_JSON_WithDescription(t *testing.T) {
pngBytes := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52}
mockResponse := `{
"data": [
{
"b64_json": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
}
]
}`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("failed to read body: %v", err)
}
var reqBody struct {
Prompt string `json:"prompt"`
}
if err := json.Unmarshal(bodyBytes, &reqBody); err != nil {
t.Fatalf("failed to unmarshal body: %v", err)
}
expectedPrompt := "two captains\n\n[Reference Image Roles]\n- image_1.png: Lâm\n- image_2.png: Quân"
if reqBody.Prompt != expectedPrompt {
t.Errorf("expected prompt %q, got %q", expectedPrompt, reqBody.Prompt)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(mockResponse))
}))
defer server.Close()
creds := fakeCreds{key: "fake-key", base: server.URL}
reg := providers.NewRegistry(nil)
tool := NewCreateImageTool(reg)
ctx := WithToolWorkspace(context.Background(), t.TempDir())
refImg1 := &referenceImage{Data: pngBytes, MimeType: "image/png", Description: "Lâm"}
refImg2 := &referenceImage{Data: pngBytes, MimeType: "image/png", Description: "Quân"}
params := map[string]any{
"prompt": "two captains",
"ref_images": []*referenceImage{refImg1, refImg2},
"_provider_type": "openai",
}
_, _, err := tool.callProvider(ctx, creds, "openai", "gpt-image-2", params)
if err != nil {
t.Fatalf("callProvider returned error: %v", err)
}
}