Add multipart image edit support
This commit is contained in:
@@ -992,7 +992,8 @@ func TestVolcesClientImageEditUsesGenerationEndpoint(t *testing.T) {
|
||||
var gotAuth string
|
||||
var gotModel string
|
||||
var gotImage string
|
||||
var gotSequential string
|
||||
var gotSequential any
|
||||
var gotSequentialPresent bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
@@ -1002,7 +1003,7 @@ func TestVolcesClientImageEditUsesGenerationEndpoint(t *testing.T) {
|
||||
}
|
||||
gotModel, _ = body["model"].(string)
|
||||
gotImage, _ = body["image"].(string)
|
||||
gotSequential, _ = body["sequential_image_generation"].(string)
|
||||
gotSequential, gotSequentialPresent = body["sequential_image_generation"]
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "img-volces-edit",
|
||||
"created": 123,
|
||||
@@ -1036,7 +1037,7 @@ func TestVolcesClientImageEditUsesGenerationEndpoint(t *testing.T) {
|
||||
if gotPath != "/images/generations" || gotAuth != "Bearer volces-key" {
|
||||
t.Fatalf("unexpected request path=%s auth=%s", gotPath, gotAuth)
|
||||
}
|
||||
if gotModel != "doubao-seedream-4-0-250828" || gotImage != "https://example.com/source.png" || gotSequential != "auto" {
|
||||
if gotModel != "doubao-seedream-4-0-250828" || gotImage != "https://example.com/source.png" || gotSequentialPresent {
|
||||
t.Fatalf("unexpected body model=%s image=%s sequential=%s", gotModel, gotImage, gotSequential)
|
||||
}
|
||||
if response.Result["id"] != "img-volces-edit" {
|
||||
@@ -1044,6 +1045,105 @@ func TestVolcesClientImageEditUsesGenerationEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolcesClientImageEditEnablesSequentialForRequestedMultipleImages(t *testing.T) {
|
||||
var gotSequential string
|
||||
var gotMaxImages float64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
gotSequential, _ = body["sequential_image_generation"].(string)
|
||||
options, _ := body["sequential_image_generation_options"].(map[string]any)
|
||||
gotMaxImages = numericValue(options["max_images"], 0)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "img-volces-edit",
|
||||
"data": []any{map[string]any{"url": "https://example.com/out.png"}},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := (VolcesClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
||||
Kind: "images.edits",
|
||||
ModelType: "image_edit",
|
||||
Body: map[string]any{
|
||||
"prompt": "make variants",
|
||||
"image": "https://example.com/source.png",
|
||||
"n": 3,
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
BaseURL: server.URL,
|
||||
ProviderModelName: "doubao-seedream-4-0-250828",
|
||||
Credentials: map[string]any{"apiKey": "volces-key"},
|
||||
Capabilities: map[string]any{
|
||||
"image_edit": map[string]any{
|
||||
"output_multiple_images": true,
|
||||
"output_max_images_count": 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run volces image edit: %v", err)
|
||||
}
|
||||
if gotSequential != "auto" || gotMaxImages != 3 {
|
||||
t.Fatalf("unexpected sequential settings mode=%s max_images=%v", gotSequential, gotMaxImages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolcesClientImageEditPreservesExplicitSequentialDisabledAndClampsMaxImages(t *testing.T) {
|
||||
var gotSequential string
|
||||
var gotMaxImages float64
|
||||
var gotOtherOption string
|
||||
options := map[string]any{"max_images": 99, "other": "keep"}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
gotSequential, _ = body["sequential_image_generation"].(string)
|
||||
gotOptions, _ := body["sequential_image_generation_options"].(map[string]any)
|
||||
gotMaxImages = numericValue(gotOptions["max_images"], 0)
|
||||
gotOtherOption, _ = gotOptions["other"].(string)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "img-volces-edit",
|
||||
"data": []any{map[string]any{"url": "https://example.com/out.png"}},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := (VolcesClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
||||
Kind: "images.edits",
|
||||
ModelType: "image_edit",
|
||||
Body: map[string]any{
|
||||
"prompt": "make variants",
|
||||
"image": "https://example.com/source.png",
|
||||
"sequential_image_generation": "disabled",
|
||||
"sequential_image_generation_options": options,
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
BaseURL: server.URL,
|
||||
ProviderModelName: "doubao-seedream-4-0-250828",
|
||||
Credentials: map[string]any{"apiKey": "volces-key"},
|
||||
Capabilities: map[string]any{
|
||||
"image_edit": map[string]any{
|
||||
"output_multiple_images": true,
|
||||
"output_max_images_count": 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run volces image edit: %v", err)
|
||||
}
|
||||
if gotSequential != "disabled" || gotMaxImages != 4 || gotOtherOption != "keep" {
|
||||
t.Fatalf("unexpected sequential settings mode=%s max_images=%v other=%s", gotSequential, gotMaxImages, gotOtherOption)
|
||||
}
|
||||
if numericValue(options["max_images"], 0) != 99 {
|
||||
t.Fatalf("request options should not be mutated: %+v", options)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolcesClientVideoSubmitsAndPollsTask(t *testing.T) {
|
||||
var submitPath string
|
||||
var pollPath string
|
||||
|
||||
@@ -208,9 +208,7 @@ func volcesImageBody(request Request) map[string]any {
|
||||
if size := widthHeightSize(body); size != "" {
|
||||
body["size"] = size
|
||||
}
|
||||
if supportsMultipleOutputs(request, request.ModelType) && body["sequential_image_generation"] == nil {
|
||||
body["sequential_image_generation"] = "auto"
|
||||
}
|
||||
normalizeVolcesSequentialImageGeneration(body, request)
|
||||
return body
|
||||
}
|
||||
|
||||
@@ -772,6 +770,119 @@ func supportsMultipleOutputs(request Request, capabilityName string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeVolcesSequentialImageGeneration(body map[string]any, request Request) {
|
||||
options, hasOptions := volcesSequentialImageGenerationOptions(body)
|
||||
normalizeVolcesSequentialMaxImages(options, request)
|
||||
if _, explicit := body["sequential_image_generation"]; explicit {
|
||||
return
|
||||
}
|
||||
if !supportsMultipleOutputs(request, request.ModelType) {
|
||||
return
|
||||
}
|
||||
count := requestedVolcesSequentialImageCount(body, options)
|
||||
if count <= 1 {
|
||||
return
|
||||
}
|
||||
body["sequential_image_generation"] = "auto"
|
||||
if !hasOptions {
|
||||
options = map[string]any{}
|
||||
body["sequential_image_generation_options"] = options
|
||||
}
|
||||
if _, ok := options["max_images"]; !ok {
|
||||
options["max_images"] = count
|
||||
normalizeVolcesSequentialMaxImages(options, request)
|
||||
}
|
||||
}
|
||||
|
||||
func volcesSequentialImageGenerationOptions(body map[string]any) (map[string]any, bool) {
|
||||
options, ok := body["sequential_image_generation_options"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
copied := map[string]any{}
|
||||
for key, value := range options {
|
||||
copied[key] = value
|
||||
}
|
||||
body["sequential_image_generation_options"] = copied
|
||||
return copied, true
|
||||
}
|
||||
|
||||
func normalizeVolcesSequentialMaxImages(options map[string]any, request Request) {
|
||||
if options == nil {
|
||||
return
|
||||
}
|
||||
raw, exists := options["max_images"]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
current := int(math.Round(numericValue(raw, 0)))
|
||||
minCount, maxCount := volcesSequentialMaxImagesRange(request)
|
||||
adjusted := current
|
||||
if minCount > 0 && adjusted < minCount {
|
||||
adjusted = minCount
|
||||
}
|
||||
if maxCount > 0 && adjusted > maxCount {
|
||||
adjusted = maxCount
|
||||
}
|
||||
if adjusted != current {
|
||||
options["max_images"] = adjusted
|
||||
}
|
||||
}
|
||||
|
||||
func requestedVolcesSequentialImageCount(body map[string]any, options map[string]any) int {
|
||||
for _, value := range []any{
|
||||
valueFromMap(options, "max_images"),
|
||||
body["count"],
|
||||
body["n"],
|
||||
body["batch_size"],
|
||||
} {
|
||||
count := int(math.Round(numericValue(value, 0)))
|
||||
if count > 0 {
|
||||
return count
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func valueFromMap(values map[string]any, key string) any {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
return values[key]
|
||||
}
|
||||
|
||||
func volcesSequentialMaxImagesRange(request Request) (int, int) {
|
||||
minCount := 1
|
||||
maxCount := 0
|
||||
for _, key := range []string{request.ModelType, "image_generate", "image_edit"} {
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
capability, _ := request.Candidate.Capabilities[key].(map[string]any)
|
||||
if capability == nil {
|
||||
continue
|
||||
}
|
||||
if value := firstPositiveInt(capability, "output_min_images_count", "outputMinImagesCount", "min_output_images", "minOutputImages", "min_images", "minImages"); value > 0 {
|
||||
minCount = value
|
||||
}
|
||||
if value := firstPositiveInt(capability, "output_max_images_count", "outputMaxImagesCount", "max_output_images", "maxOutputImages", "max_images", "maxImages"); value > 0 {
|
||||
maxCount = value
|
||||
}
|
||||
return minCount, maxCount
|
||||
}
|
||||
return minCount, maxCount
|
||||
}
|
||||
|
||||
func firstPositiveInt(values map[string]any, keys ...string) int {
|
||||
for _, key := range keys {
|
||||
value := int(math.Round(numericValue(values[key], 0)))
|
||||
if value > 0 {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func widthHeightSize(body map[string]any) string {
|
||||
width := numericValue(body["width"], 0)
|
||||
height := numericValue(body["height"], 0)
|
||||
|
||||
Reference in New Issue
Block a user