From 214ffa1dfc49f46e04c58e3d3c4f461c1eac92a8 Mon Sep 17 00:00:00 2001 From: wtgoku <2687861+wtgoku@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:40:49 +0800 Subject: [PATCH] Route Seedream 4.5 image flows through the NewAPI generations endpoint Seedream image generation in the server was still split across the generic text2image path and the legacy multipart image edit path. That shape does not match Volcengine Seedream 4.5, which expects JSON requests to /v1/images/generations for both text2image and img2img, with reference images carried through the image field. Constraint: Seedream 4.5 and 5.0 use /v1/images/generations rather than the older multipart /v1/images/edits contract Constraint: Existing Gemini and other image routes must keep their current behavior unchanged Rejected: Keep using /v1/images/edits for Seedream img2img | upstream rejects the request body shape Rejected: Special-case the CLI input contract | the server route adapter owns upstream normalization Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep Seedream routes on the JSON generations path; do not reintroduce multipart edits for these models without revalidating the upstream API contract Tested: go test ./internal/server; go test ./...; go build -o /tmp/popiartserver-verify ./cmd/popiartserver; test-server txt2img/img2img/img2video smoke with Seedream 4.5 + Vidu Not-tested: Seedream 5.0 live smoke against the test server --- internal/server/models_test.go | 16 +++- internal/server/newapi.go | 136 +++++++++++++++++++++++++++ internal/server/newapi_image_test.go | 124 ++++++++++++++++++++++++ 3 files changed, 274 insertions(+), 2 deletions(-) diff --git a/internal/server/models_test.go b/internal/server/models_test.go index d0e7537..14d1858 100644 --- a/internal/server/models_test.go +++ b/internal/server/models_test.go @@ -22,6 +22,7 @@ func TestHandleModelsLoadsDynamicCatalogFromNewAPI(t *testing.T) { "success": true, "data": [ {"id":"gpt-image-1","owned_by":"openai","supported_endpoint_types":["image-generation"]}, + {"id":"seedream-4-5-251128","owned_by":"custom","supported_endpoint_types":[]}, {"id":"sora-2","owned_by":"openai","supported_endpoint_types":["openai-video"]}, {"id":"viduq2","owned_by":"custom","supported_endpoint_types":[]}, {"id":"custom-i2v-preview","owned_by":"custom","supported_endpoint_types":[]}, @@ -73,8 +74,8 @@ func TestHandleModelsLoadsDynamicCatalogFromNewAPI(t *testing.T) { if !resp.OK { t.Fatalf("expected ok response, got %s", rec.Body.String()) } - if len(resp.Data.Items) != 4 { - t.Fatalf("expected 4 supported media models, got %d: %s", len(resp.Data.Items), rec.Body.String()) + if len(resp.Data.Items) != 5 { + t.Fatalf("expected 5 supported media models, got %d: %s", len(resp.Data.Items), rec.Body.String()) } byID := make(map[string]model, len(resp.Data.Items)) @@ -99,6 +100,17 @@ func TestHandleModelsLoadsDynamicCatalogFromNewAPI(t *testing.T) { t.Fatalf("unexpected image capabilities: %#v", imageModel.Capabilities) } + seedreamModel, exists := byID["seedream-4-5-251128"] + if !exists { + t.Fatal("expected seedream-4-5-251128 in dynamic model list") + } + if seedreamModel.Type != "image" { + t.Fatalf("expected seedream-4-5-251128 type image, got %q", seedreamModel.Type) + } + if len(seedreamModel.Capabilities) != 2 || seedreamModel.Capabilities[0] != "text2image" || seedreamModel.Capabilities[1] != "img2img" { + t.Fatalf("unexpected seedream capabilities: %#v", seedreamModel.Capabilities) + } + videoModel, exists := byID["sora-2"] if !exists { t.Fatal("expected sora-2 in dynamic model list") diff --git a/internal/server/newapi.go b/internal/server/newapi.go index 25a5807..b9f4493 100644 --- a/internal/server/newapi.go +++ b/internal/server/newapi.go @@ -397,6 +397,7 @@ func classifyModelIDFallback(modelID string) (string, []string) { strings.Contains(lowerID, "i2v"): return "video", []string{"text2video", "image2video"} case strings.Contains(lowerID, "image"), + strings.Contains(lowerID, "seedream"), strings.Contains(lowerID, "flux"), strings.Contains(lowerID, "recraft"), strings.Contains(lowerID, "midjourney"), @@ -416,6 +417,9 @@ func (c *newAPIClient) generateImageRefs(ctx context.Context, token, modelID str if useGeminiImageGenerateContent(modelID) { return c.generateGeminiImageRefs(ctx, token, modelID, input, nil) } + if useSeedreamImageGenerations(modelID) { + return c.generateSeedreamImageRefs(ctx, token, modelID, input, nil) + } token = c.authorizedToken(token) if token == "" { return nil, nil, errors.New("PopiNewAPI token is not configured") @@ -477,6 +481,9 @@ func (c *newAPIClient) generateEditedImageRefs(ctx context.Context, token, model if useGeminiImageGenerateContent(modelID) { return c.generateGeminiImageRefs(ctx, token, modelID, input, []imageEditReference{ref}) } + if useSeedreamImageGenerations(modelID) { + return c.generateSeedreamImageRefs(ctx, token, modelID, input, []imageEditReference{ref}) + } token = c.authorizedImageEditToken(token) if token == "" { return nil, nil, errors.New("PopiNewAPI token is not configured") @@ -549,6 +556,74 @@ func (c *newAPIClient) generateEditedImageRefs(ctx context.Context, token, model return decodeImageGenerationResponse(resp.StatusCode, respBody, modelID) } +func (c *newAPIClient) generateSeedreamImageRefs(ctx context.Context, token, modelID string, input map[string]any, refs []imageEditReference) ([]resultRef, map[string]any, error) { + if !c.enabled() { + return nil, nil, errors.New("PopiNewAPI base URL is not configured") + } + token = c.authorizedToken(token) + if token == "" { + return nil, nil, errors.New("PopiNewAPI token is not configured") + } + + prompt := strings.TrimSpace(stringValue(input["prompt"])) + if prompt == "" { + return nil, nil, errors.New("prompt is required") + } + + payload := map[string]any{ + "model": modelID, + "prompt": prompt, + "response_format": defaultString(strings.TrimSpace(stringValue(input["response_format"])), "url"), + } + if size := resolveSeedreamImageSize(modelID, input); size != "" { + payload["size"] = size + } + if value, ok := input["seed"]; ok { + payload["seed"] = value + } + if value, ok := input["watermark"]; ok { + payload["watermark"] = value + } + if value, ok := input["sequential_image_generation"]; ok { + payload["sequential_image_generation"] = value + } + if value, ok := input["sequential_image_generation_options"]; ok { + payload["sequential_image_generation_options"] = value + } + if len(refs) > 0 { + imageInput, err := encodeSeedreamReferenceInputs(refs) + if err != nil { + return nil, nil, err + } + payload["image"] = imageInput + } + + body, err := json.Marshal(payload) + if err != nil { + return nil, nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/images/generations", bytes.NewReader(body)) + if err != nil { + return nil, nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, err + } + + return decodeImageGenerationResponse(resp.StatusCode, respBody, modelID) +} + func (c *newAPIClient) generateGeminiImageRefs(ctx context.Context, token, modelID string, input map[string]any, refs []imageEditReference) ([]resultRef, map[string]any, error) { if !c.enabled() { return nil, nil, errors.New("PopiNewAPI base URL is not configured") @@ -1035,6 +1110,11 @@ func useGeminiImageGenerateContent(modelID string) bool { return strings.Contains(lowerID, "gemini") || strings.Contains(lowerID, "banana") } +func useSeedreamImageGenerations(modelID string) bool { + lowerID := strings.ToLower(strings.TrimSpace(modelID)) + return strings.Contains(lowerID, "seedream") +} + func resolveGeminiAspectRatio(input map[string]any) string { if aspectRatio := strings.TrimSpace(stringValue(input["aspect_ratio"])); aspectRatio != "" { return aspectRatio @@ -1074,6 +1154,62 @@ func resolveGeminiImageSize(input map[string]any) string { } } +func resolveSeedreamImageSize(modelID string, input map[string]any) string { + supported := supportedSeedreamSizes(modelID) + for _, value := range []string{ + strings.ToUpper(strings.TrimSpace(stringValue(input["resolution"]))), + strings.ToUpper(strings.TrimSpace(stringValue(input["size"]))), + } { + if _, ok := supported[value]; ok { + return value + } + } + return "2K" +} + +func supportedSeedreamSizes(modelID string) map[string]struct{} { + lowerID := strings.ToLower(strings.TrimSpace(modelID)) + if strings.Contains(lowerID, "5-0") { + return map[string]struct{}{ + "2K": {}, + "3K": {}, + } + } + return map[string]struct{}{ + "2K": {}, + "4K": {}, + } +} + +func encodeSeedreamReferenceInputs(refs []imageEditReference) (any, error) { + encoded := make([]string, 0, len(refs)) + for _, ref := range refs { + value, err := encodeSeedreamReference(ref) + if err != nil { + return nil, err + } + encoded = append(encoded, value) + } + if len(encoded) == 1 { + return encoded[0], nil + } + return encoded, nil +} + +func encodeSeedreamReference(ref imageEditReference) (string, error) { + if rawURL := strings.TrimSpace(ref.URL); rawURL != "" { + return rawURL, nil + } + if len(ref.Content) == 0 { + return "", errors.New("reference image content is required") + } + contentType := strings.TrimSpace(ref.ContentType) + if contentType == "" { + contentType = http.DetectContentType(ref.Content) + } + return "data:" + contentType + ";base64," + base64.StdEncoding.EncodeToString(ref.Content), nil +} + func (c *newAPIClient) openResultRef(ctx context.Context, token string, ref resultRef) (string, int64, io.ReadCloser, error) { switch ref.Kind { case "local_path": diff --git a/internal/server/newapi_image_test.go b/internal/server/newapi_image_test.go index aee0042..2855f8b 100644 --- a/internal/server/newapi_image_test.go +++ b/internal/server/newapi_image_test.go @@ -102,6 +102,118 @@ func TestGenerateGeminiImageRefsUsesGenerateContentEndpoint(t *testing.T) { } } +func TestGenerateSeedreamImageRefsUsesImagesGenerationsEndpoint(t *testing.T) { + var gotPath string + var gotAuth string + var gotBody map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "created": 1757388756, + "data": [{"url":"https://example.com/generated.png","size":"2720x1536"}], + "usage": {"generated_images": 1} + }`)) + })) + defer srv.Close() + + client := &newAPIClient{ + baseURL: strings.TrimRight(srv.URL, "/"), + httpClient: srv.Client(), + } + + refs, usage, err := client.generateImageRefs(context.Background(), "sk-test", "seedream-4-5-251128", map[string]any{ + "prompt": "draw a golden retriever in the park", + "size": "1024x1024", + }) + if err != nil { + t.Fatalf("generateImageRefs: %v", err) + } + if gotPath != "/v1/images/generations" { + t.Fatalf("unexpected path: %s", gotPath) + } + if gotAuth != "Bearer sk-test" { + t.Fatalf("unexpected auth header: %q", gotAuth) + } + if gotBody["size"] != "2K" { + t.Fatalf("expected normalized 2K size, got %#v", gotBody["size"]) + } + if gotBody["response_format"] != "url" { + t.Fatalf("expected default response_format=url, got %#v", gotBody["response_format"]) + } + if _, exists := gotBody["image"]; exists { + t.Fatalf("did not expect image field for text2image payload: %#v", gotBody["image"]) + } + if len(refs) != 1 || refs[0].Kind != "url" || refs[0].URL != "https://example.com/generated.png" { + t.Fatalf("unexpected refs: %#v", refs) + } + if usage["generated_images"] != float64(1) { + t.Fatalf("unexpected usage: %#v", usage) + } +} + +func TestGenerateEditedImageRefsUsesSeedreamImagesGenerationsEndpoint(t *testing.T) { + var gotPath string + var gotAuth string + var gotBody map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "created": 1757388756, + "data": [{"url":"https://example.com/edited.png","size":"2720x1536"}], + "usage": {"generated_images": 1} + }`)) + })) + defer srv.Close() + + client := &newAPIClient{ + baseURL: strings.TrimRight(srv.URL, "/"), + httpClient: srv.Client(), + } + + refs, usage, err := client.generateEditedImageRefs(context.Background(), "sk-test", "seedream-4-5-251128", map[string]any{ + "prompt": "edit this image into a dusk scene", + "size": "1024x1536", + }, imageEditReference{ + Filename: "source.jpg", + ContentType: "image/jpeg", + Content: []byte("binary-image"), + URL: "https://example.com/reference.jpg", + }) + if err != nil { + t.Fatalf("generateEditedImageRefs: %v", err) + } + if gotPath != "/v1/images/generations" { + t.Fatalf("unexpected path: %s", gotPath) + } + if gotAuth != "Bearer sk-test" { + t.Fatalf("unexpected auth header: %q", gotAuth) + } + if gotBody["size"] != "2K" { + t.Fatalf("expected normalized 2K size, got %#v", gotBody["size"]) + } + if gotBody["image"] != "https://example.com/reference.jpg" { + t.Fatalf("expected image URL payload, got %#v", gotBody["image"]) + } + if len(refs) != 1 || refs[0].Kind != "url" || refs[0].URL != "https://example.com/edited.png" { + t.Fatalf("unexpected refs: %#v", refs) + } + if usage["generated_images"] != float64(1) { + t.Fatalf("unexpected usage: %#v", usage) + } +} + func TestResolveGeminiAspectRatioFromSize(t *testing.T) { if got := resolveGeminiAspectRatio(map[string]any{"size": "1792x1024"}); got != "16:9" { t.Fatalf("expected 16:9, got %q", got) @@ -119,3 +231,15 @@ func TestResolveGeminiImageSizeFromResolutionOrSize(t *testing.T) { t.Fatalf("expected 1K from square preset, got %q", got) } } + +func TestResolveSeedreamImageSizeUsesSupportedPresetOrFallback(t *testing.T) { + if got := resolveSeedreamImageSize("doubao-seedream-4-5-251128", map[string]any{"resolution": "4K"}); got != "4K" { + t.Fatalf("expected 4K for Seedream 4.5, got %q", got) + } + if got := resolveSeedreamImageSize("doubao-seedream-4-5-251128", map[string]any{"size": "1024x1536"}); got != "2K" { + t.Fatalf("expected 2K fallback for pixel size, got %q", got) + } + if got := resolveSeedreamImageSize("doubao-seedream-5-0-260128", map[string]any{"resolution": "3K"}); got != "3K" { + t.Fatalf("expected 3K for Seedream 5.0, got %q", got) + } +}