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
This commit is contained in:
wtgoku
2026-04-10 17:40:49 +08:00
parent b99c49790a
commit 214ffa1dfc
3 changed files with 274 additions and 2 deletions
+124
View File
@@ -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)
}
}