feat(gateway): add Seedream 5.0 Pro
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -1673,6 +1674,119 @@ func TestVolcesClientImageEditPreservesExplicitSequentialDisabledAndClampsMaxIma
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolcesClientSeedreamProGenerationUsesRealModelAndSingleOutput(t *testing.T) {
|
||||
var captured map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "img-seedream-pro",
|
||||
"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.generations",
|
||||
ModelType: "image_generate",
|
||||
Model: "Seedream-5.0-Pro",
|
||||
Body: map[string]any{
|
||||
"model": "Seedream-5.0-Pro",
|
||||
"prompt": "draw a mountain",
|
||||
"resolution": "2K",
|
||||
"n": 4,
|
||||
"sequential_image_generation": "auto",
|
||||
"sequential_image_generation_options": map[string]any{"max_images": 4},
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
BaseURL: server.URL,
|
||||
ModelName: "Seedream-5.0-Pro",
|
||||
ModelAlias: "Seedream-5.0-Pro",
|
||||
ProviderModelName: "doubao-seedream-5-0-pro-260628",
|
||||
Credentials: map[string]any{"apiKey": "volces-key"},
|
||||
Capabilities: map[string]any{
|
||||
"image_generate": map[string]any{
|
||||
"output_multiple_images": false,
|
||||
"output_max_images_count": 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run Seedream Pro generation: %v", err)
|
||||
}
|
||||
if captured["model"] != "doubao-seedream-5-0-pro-260628" || captured["size"] != "2K" {
|
||||
t.Fatalf("Seedream Pro should use the real model and resolution size, got %+v", captured)
|
||||
}
|
||||
if captured["n"] != float64(1) {
|
||||
t.Fatalf("Seedream Pro should force single-image output, got %+v", captured)
|
||||
}
|
||||
if _, ok := captured["sequential_image_generation"]; ok {
|
||||
t.Fatalf("Seedream Pro request must not include sequential image generation, got %+v", captured)
|
||||
}
|
||||
if _, ok := captured["sequential_image_generation_options"]; ok {
|
||||
t.Fatalf("Seedream Pro request must not include sequential image options, got %+v", captured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolcesClientSeedreamProEditForwardsTenReferencesAndCustomSize(t *testing.T) {
|
||||
var captured map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "img-seedream-pro-edit",
|
||||
"data": []any{map[string]any{"url": "https://example.com/out.png"}},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
images := make([]any, 10)
|
||||
for index := range images {
|
||||
images[index] = fmt.Sprintf("https://example.com/reference-%d.png", index+1)
|
||||
}
|
||||
_, err := (VolcesClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
||||
Kind: "images.edits",
|
||||
ModelType: "image_edit",
|
||||
Model: "Seedream-5.0-Pro",
|
||||
Body: map[string]any{
|
||||
"model": "Seedream-5.0-Pro",
|
||||
"prompt": "combine the references",
|
||||
"image": images,
|
||||
"width": 1600,
|
||||
"height": 900,
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
BaseURL: server.URL,
|
||||
ModelName: "Seedream-5.0-Pro",
|
||||
ModelAlias: "Seedream-5.0-Pro",
|
||||
ProviderModelName: "doubao-seedream-5-0-pro-260628",
|
||||
Credentials: map[string]any{"apiKey": "volces-key"},
|
||||
Capabilities: map[string]any{
|
||||
"image_edit": map[string]any{
|
||||
"input_multiple_images": true,
|
||||
"input_max_images_count": 10,
|
||||
"output_multiple_images": false,
|
||||
"output_max_images_count": 1,
|
||||
"input_max_file_size_bytes": 30 * 1024 * 1024,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run Seedream Pro edit: %v", err)
|
||||
}
|
||||
gotImages, _ := captured["image"].([]any)
|
||||
if captured["model"] != "doubao-seedream-5-0-pro-260628" || captured["size"] != "1600x900" || len(gotImages) != 10 {
|
||||
t.Fatalf("Seedream Pro edit should preserve ten references and custom size, got %+v", captured)
|
||||
}
|
||||
if _, ok := captured["sequential_image_generation"]; ok {
|
||||
t.Fatalf("Seedream Pro edit must not include sequential image generation, got %+v", captured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolcesClientVideoSubmitsAndPollsTask(t *testing.T) {
|
||||
var submitPath string
|
||||
var pollPath string
|
||||
|
||||
@@ -781,14 +781,22 @@ func supportsMultipleOutputs(request Request, capabilityName string) bool {
|
||||
}
|
||||
|
||||
func normalizeVolcesSequentialImageGeneration(body map[string]any, request Request) {
|
||||
if !supportsMultipleOutputs(request, request.ModelType) {
|
||||
delete(body, "sequential_image_generation")
|
||||
delete(body, "sequential_image_generation_options")
|
||||
if numericValue(body["n"], 1) > 1 {
|
||||
body["n"] = 1
|
||||
}
|
||||
if numericValue(body["batch_size"], 1) > 1 {
|
||||
body["batch_size"] = 1
|
||||
}
|
||||
return
|
||||
}
|
||||
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
|
||||
|
||||
@@ -125,6 +125,7 @@ func TestCoreLocalFlow(t *testing.T) {
|
||||
t.Fatalf("connect test pool: %v", err)
|
||||
}
|
||||
defer testPool.Close()
|
||||
assertSeedreamProCatalogMigration(t, ctx, testPool)
|
||||
if _, err := testPool.Exec(ctx, `UPDATE gateway_users SET roles = '["admin"]'::jsonb WHERE username = $1`, username); err != nil {
|
||||
t.Fatalf("promote smoke user: %v", err)
|
||||
}
|
||||
@@ -1794,6 +1795,42 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
}
|
||||
}
|
||||
|
||||
func assertSeedreamProCatalogMigration(t *testing.T, ctx context.Context, pool *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
var valid bool
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM base_model_catalog model
|
||||
WHERE model.provider_key = 'volces'
|
||||
AND model.canonical_model_key = 'volces:doubao-seedream-5-0-pro-260628'
|
||||
AND model.provider_model_name = 'doubao-seedream-5-0-pro-260628'
|
||||
AND model.display_name = 'Seedream-5.0-Pro'
|
||||
AND model.display_name !~ '[[:space:]]'
|
||||
AND model.model_type = '["image_edit","image_generate"]'::jsonb
|
||||
AND model.capabilities->'image_edit'->>'input_multiple_images' = 'true'
|
||||
AND model.capabilities->'image_edit'->>'input_max_images_count' = '10'
|
||||
AND model.capabilities->'image_edit'->>'input_max_file_size_bytes' = '31457280'
|
||||
AND model.capabilities->'image_edit'->'output_resolutions' = '["1K","2K"]'::jsonb
|
||||
AND model.capabilities->'image_edit'->'output_size_range' = '[921600,4624220]'::jsonb
|
||||
AND model.capabilities->'image_edit'->'aspect_ratio_range' = '[0.0625,16]'::jsonb
|
||||
AND model.capabilities->'image_edit'->>'output_multiple_images' = 'false'
|
||||
AND model.capabilities->'image_edit'->>'output_max_images_count' = '1'
|
||||
AND model.capabilities->'image_generate'->>'allow_custom_width_height_size' = 'true'
|
||||
AND model.capabilities->'image_generate'->>'output_multiple_images' = 'false'
|
||||
AND model.capabilities->>'stream' = 'false'
|
||||
AND model.capabilities->>'supportWebSearch' = 'false'
|
||||
AND model.default_rate_limit_policy->'rules'->0->>'limit' = '500'
|
||||
AND model.default_snapshot->>'modelAlias' = 'Seedream-5.0-Pro'
|
||||
AND model.default_snapshot->>'displayName' = 'Seedream 5.0 Pro'
|
||||
)`).Scan(&valid); err != nil {
|
||||
t.Fatalf("query Seedream 5.0 Pro catalog migration: %v", err)
|
||||
}
|
||||
if !valid {
|
||||
t.Fatal("Seedream 5.0 Pro catalog migration does not match the expected alias, upstream model, capabilities, or rate limit")
|
||||
}
|
||||
}
|
||||
|
||||
func doJSON(t *testing.T, baseURL string, method string, path string, token string, payload any, expectedStatus int, out any) {
|
||||
t.Helper()
|
||||
doJSONWithHeaders(t, baseURL, method, path, token, payload, nil, expectedStatus, out)
|
||||
|
||||
@@ -238,7 +238,7 @@ func buildModelCatalog(
|
||||
current = &catalogGroup{
|
||||
key: key,
|
||||
alias: firstNonEmpty(model.ModelAlias, model.DisplayName, model.ModelName),
|
||||
displayName: firstNonEmpty(model.ModelAlias, model.DisplayName, model.ModelName),
|
||||
displayName: firstNonEmpty(model.DisplayName, model.ModelAlias, model.ModelName),
|
||||
modelName: model.ModelName,
|
||||
modelType: cloneStringSlice(model.ModelType),
|
||||
capabilities: cloneObject(model.Capabilities),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
@@ -114,6 +115,36 @@ func TestBuildModelCatalogAggregatesSources(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelCatalogKeepsDisplayNameSeparateFromCallAlias(t *testing.T) {
|
||||
models := []store.PlatformModel{
|
||||
{
|
||||
ID: "seedream-pro",
|
||||
PlatformID: "platform-volces",
|
||||
ModelName: "Seedream-5.0-Pro",
|
||||
ProviderModelName: "doubao-seedream-5-0-pro-260628",
|
||||
ModelAlias: "Seedream-5.0-Pro",
|
||||
ModelType: store.StringList{"image_edit", "image_generate"},
|
||||
DisplayName: "Seedream 5.0 Pro",
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
platforms := []store.Platform{
|
||||
{ID: "platform-volces", Provider: "volces", Name: "火山引擎", Status: "enabled"},
|
||||
}
|
||||
|
||||
response := buildModelCatalog(models, platforms, nil, nil, nil, nil, nil)
|
||||
if len(response.Items) != 1 {
|
||||
t.Fatalf("expected one Seedream Pro catalog item, got %+v", response.Items)
|
||||
}
|
||||
item := response.Items[0]
|
||||
if item.Alias != "Seedream-5.0-Pro" || item.DisplayName != "Seedream 5.0 Pro" || item.ModelName != "Seedream-5.0-Pro" {
|
||||
t.Fatalf("catalog should separate the call alias from the display name, got %+v", item)
|
||||
}
|
||||
if strings.Contains(item.Alias, " ") {
|
||||
t.Fatalf("Seedream Pro call alias must not contain spaces, got %q", item.Alias)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelCatalogUsesBaseModelProviderForProviderFilters(t *testing.T) {
|
||||
models := []store.PlatformModel{
|
||||
{
|
||||
|
||||
@@ -722,10 +722,13 @@ func (imageCountProcessor) ShouldProcess(params map[string]any, modelType string
|
||||
|
||||
func (imageCountProcessor) Process(params map[string]any, modelType string, context *paramProcessContext) bool {
|
||||
capability := capabilityForType(context.modelCapability, modelType)
|
||||
if capability == nil || !boolFromAny(capability["output_multiple_images"]) {
|
||||
if capability == nil {
|
||||
return true
|
||||
}
|
||||
maxCount := int(math.Round(floatFromAny(capability["output_max_images_count"])))
|
||||
if !boolFromAny(capability["output_multiple_images"]) {
|
||||
maxCount = 1
|
||||
}
|
||||
if maxCount <= 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -662,6 +662,31 @@ func TestParamProcessorImageResolutionAndOutputCount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamProcessorForcesSingleImageWhenMultipleOutputsAreDisabled(t *testing.T) {
|
||||
body := map[string]any{
|
||||
"model": "Seedream-5.0-Pro",
|
||||
"prompt": "draw",
|
||||
"n": 4,
|
||||
}
|
||||
candidate := store.RuntimeModelCandidate{
|
||||
ModelType: "image_generate",
|
||||
Capabilities: map[string]any{
|
||||
"image_generate": map[string]any{
|
||||
"output_multiple_images": false,
|
||||
"output_max_images_count": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := preprocessRequestWithLog("images.generations", body, candidate)
|
||||
if result.Body["n"] != 1 {
|
||||
t.Fatalf("single-output image model should force n=1, got %+v", result.Body)
|
||||
}
|
||||
if len(result.Log.Changes) == 0 || result.Log.Changes[len(result.Log.Changes)-1].CapabilityPath != "capabilities.image_generate.output_max_images_count" {
|
||||
t.Fatalf("single-output adjustment should be logged against output_max_images_count, got %+v", result.Log.Changes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamProcessorImageSizeConstraintsNormalizeExplicitDimensions(t *testing.T) {
|
||||
body := map[string]any{
|
||||
"model": "doubao-5.0图像编辑",
|
||||
|
||||
Reference in New Issue
Block a user