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) + } +}