diff --git a/apps/api/internal/clients/clients_test.go b/apps/api/internal/clients/clients_test.go index 2843d0e..7297369 100644 --- a/apps/api/internal/clients/clients_test.go +++ b/apps/api/internal/clients/clients_test.go @@ -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 diff --git a/apps/api/internal/clients/volces.go b/apps/api/internal/clients/volces.go index 0de43af..dd01b78 100644 --- a/apps/api/internal/clients/volces.go +++ b/apps/api/internal/clients/volces.go @@ -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 diff --git a/apps/api/internal/httpapi/core_flow_integration_test.go b/apps/api/internal/httpapi/core_flow_integration_test.go index 321dd67..a2bea6c 100644 --- a/apps/api/internal/httpapi/core_flow_integration_test.go +++ b/apps/api/internal/httpapi/core_flow_integration_test.go @@ -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) diff --git a/apps/api/internal/httpapi/model_catalog.go b/apps/api/internal/httpapi/model_catalog.go index 73d274e..87deb2b 100644 --- a/apps/api/internal/httpapi/model_catalog.go +++ b/apps/api/internal/httpapi/model_catalog.go @@ -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), diff --git a/apps/api/internal/httpapi/model_catalog_test.go b/apps/api/internal/httpapi/model_catalog_test.go index a28759f..22a7f29 100644 --- a/apps/api/internal/httpapi/model_catalog_test.go +++ b/apps/api/internal/httpapi/model_catalog_test.go @@ -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{ { diff --git a/apps/api/internal/runner/param_processor_media.go b/apps/api/internal/runner/param_processor_media.go index 7fd3af6..11dbbda 100644 --- a/apps/api/internal/runner/param_processor_media.go +++ b/apps/api/internal/runner/param_processor_media.go @@ -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 } diff --git a/apps/api/internal/runner/param_processor_test.go b/apps/api/internal/runner/param_processor_test.go index d273b99..1c53b44 100644 --- a/apps/api/internal/runner/param_processor_test.go +++ b/apps/api/internal/runner/param_processor_test.go @@ -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图像编辑", diff --git a/apps/api/migrations/0060_volces_seedream50_pro.sql b/apps/api/migrations/0060_volces_seedream50_pro.sql new file mode 100644 index 0000000..93428bd --- /dev/null +++ b/apps/api/migrations/0060_volces_seedream50_pro.sql @@ -0,0 +1,237 @@ +WITH seedream50_pro AS ( + SELECT + 'volces:doubao-seedream-5-0-pro-260628' AS canonical_model_key, + 'doubao-seedream-5-0-pro-260628' AS provider_model_name, + 'Seedream-5.0-Pro' AS model_alias, + 'Seedream 5.0 Pro' AS display_name, + '["image_edit","image_generate"]'::jsonb AS model_type, + '{ + "image_edit": { + "input_multiple_images": true, + "input_max_images_count": 10, + "input_max_file_size_bytes": 31457280, + "output_resolutions": ["1K", "2K"], + "size_param_format": "resolution", + "allow_custom_width_height_size": true, + "output_max_size": 4624220, + "output_size_range": [921600, 4624220], + "aspect_ratio_range": [0.0625, 16], + "output_multiple_images": false, + "output_max_images_count": 1 + }, + "image_generate": { + "output_resolutions": ["1K", "2K"], + "size_param_format": "resolution", + "allow_custom_width_height_size": true, + "output_max_size": 4624220, + "output_size_range": [921600, 4624220], + "aspect_ratio_range": [0.0625, 16], + "output_multiple_images": false, + "output_max_images_count": 1 + }, + "stream": false, + "supportWebSearch": false, + "originalTypes": ["image_edit", "image_generate"] + }'::jsonb AS capabilities, + '{ + "image": { + "basePrice": 10, + "baseWeight": 1, + "dynamicWeight": {"1K": 1, "2K": 1.5, "3K": 1.75, "4K": 2, "8K": 4} + }, + "currency": "resource" + }'::jsonb AS base_billing_config, + '{"rules":[{"metric":"rpm","limit":500,"windowSeconds":60}]}'::jsonb AS rate_limit_policy, + '{ + "source": "server-main.integration-platform", + "sourceProviderCode": "volces", + "sourceProviderName": "火山引擎(豆包)", + "sourceSpecType": "volces", + "originalTypes": ["image_edit", "image_generate"], + "alias": "Seedream-5.0-Pro", + "displayName": "Seedream 5.0 Pro", + "description": "高精度图片生成与多参考图编辑,支持 1K、2K 和自定义尺寸", + "iconPath": "https://ecmb.bdimg.com/tam-ogel/1801637412_-164881388_88_88.jpg", + "billingType": "external-api", + "billingMode": "", + "referenceModel": "", + "modelWeight": null, + "selectable": true, + "rawModel": { + "name": "doubao-seedream-5-0-pro-260628", + "types": ["image_edit", "image_generate"], + "alias": "Seedream-5.0-Pro", + "display_name": "Seedream 5.0 Pro", + "description": "高精度图片生成与多参考图编辑,支持 1K、2K 和自定义尺寸", + "icon_path": "https://ecmb.bdimg.com/tam-ogel/1801637412_-164881388_88_88.jpg", + "model_limits": {"max_request_per_minute": 500} + } + }'::jsonb AS metadata +) +INSERT INTO base_model_catalog ( + provider_id, + provider_key, + canonical_model_key, + provider_model_name, + model_type, + display_name, + capabilities, + base_billing_config, + default_rate_limit_policy, + metadata, + catalog_type, + default_snapshot, + status +) +SELECT + (SELECT id FROM model_catalog_providers WHERE provider_key = 'volces' OR provider_code = 'volces' LIMIT 1), + 'volces', + model.canonical_model_key, + model.provider_model_name, + model.model_type, + model.model_alias, + model.capabilities, + model.base_billing_config, + model.rate_limit_policy, + jsonb_set(model.metadata, '{rawModel,capabilities}', model.capabilities, true), + 'system', + jsonb_build_object( + 'providerKey', 'volces', + 'canonicalModelKey', model.canonical_model_key, + 'providerModelName', model.provider_model_name, + 'modelType', model.model_type, + 'modelAlias', model.model_alias, + 'displayName', model.display_name, + 'capabilities', model.capabilities, + 'baseBillingConfig', model.base_billing_config, + 'defaultRateLimitPolicy', model.rate_limit_policy, + 'metadata', jsonb_set(model.metadata, '{rawModel,capabilities}', model.capabilities, true), + 'status', 'active' + ), + 'active' +FROM seedream50_pro model +ON CONFLICT (canonical_model_key) DO UPDATE +SET provider_id = EXCLUDED.provider_id, + provider_key = EXCLUDED.provider_key, + provider_model_name = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.provider_model_name ELSE base_model_catalog.provider_model_name END, + model_type = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.model_type ELSE base_model_catalog.model_type END, + display_name = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.display_name ELSE base_model_catalog.display_name END, + capabilities = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.capabilities ELSE base_model_catalog.capabilities END, + base_billing_config = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.base_billing_config ELSE base_model_catalog.base_billing_config END, + default_rate_limit_policy = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.default_rate_limit_policy ELSE base_model_catalog.default_rate_limit_policy END, + metadata = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.metadata ELSE base_model_catalog.metadata END, + catalog_type = CASE WHEN base_model_catalog.customized_at IS NULL THEN 'system' ELSE base_model_catalog.catalog_type END, + default_snapshot = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.default_snapshot ELSE base_model_catalog.default_snapshot END, + status = CASE WHEN base_model_catalog.customized_at IS NULL THEN 'active' ELSE base_model_catalog.status END, + updated_at = now(); + +WITH seedream50_pro AS ( + SELECT + base_model.id AS base_model_id, + base_model.provider_model_name, + 'Seedream-5.0-Pro' AS model_alias, + 'Seedream 5.0 Pro' AS display_name, + base_model.model_type, + base_model.capabilities, + base_model.base_billing_config, + base_model.default_rate_limit_policy + FROM base_model_catalog base_model + WHERE base_model.canonical_model_key = 'volces:doubao-seedream-5-0-pro-260628' +) +UPDATE platform_models model +SET base_model_id = seedream.base_model_id, + model_name = CASE + WHEN model.model_name = seedream.model_alias THEN seedream.model_alias + WHEN NOT EXISTS ( + SELECT 1 + FROM platform_models peer + WHERE peer.platform_id = model.platform_id + AND peer.id <> model.id + AND peer.model_name = seedream.model_alias + ) THEN seedream.model_alias + ELSE model.model_name + END, + provider_model_name = seedream.provider_model_name, + model_alias = seedream.model_alias, + model_type = seedream.model_type, + display_name = seedream.display_name, + capabilities = seedream.capabilities, + pricing_mode = 'inherit_discount', + billing_config = seedream.base_billing_config, + retry_policy = '{"enabled":true,"maxAttempts":1}'::jsonb, + rate_limit_policy = seedream.default_rate_limit_policy, + enabled = NOT EXISTS ( + SELECT 1 + FROM platform_models peer + WHERE peer.platform_id = model.platform_id + AND peer.id <> model.id + AND peer.model_name = seedream.model_alias + ), + updated_at = now() +FROM integration_platforms platform +JOIN seedream50_pro seedream ON TRUE +WHERE model.platform_id = platform.id + AND platform.provider = 'volces' + AND platform.deleted_at IS NULL + AND ( + model.model_name IN ('Seedream-5.0-Pro', 'doubao-seedream-5-0-pro-260628') + OR model.provider_model_name = 'doubao-seedream-5-0-pro-260628' + OR model.model_alias = 'Seedream-5.0-Pro' + ); + +INSERT INTO platform_models ( + platform_id, + base_model_id, + model_name, + provider_model_name, + model_alias, + model_type, + display_name, + capabilities, + pricing_mode, + billing_config, + retry_policy, + rate_limit_policy, + enabled +) +SELECT + platform.id, + base_model.id, + 'Seedream-5.0-Pro', + base_model.provider_model_name, + 'Seedream-5.0-Pro', + base_model.model_type, + 'Seedream 5.0 Pro', + base_model.capabilities, + 'inherit_discount', + base_model.base_billing_config, + '{"enabled":true,"maxAttempts":1}'::jsonb, + base_model.default_rate_limit_policy, + true +FROM integration_platforms platform +JOIN base_model_catalog base_model ON base_model.canonical_model_key = 'volces:doubao-seedream-5-0-pro-260628' +WHERE platform.provider = 'volces' + AND platform.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM platform_models existing + WHERE existing.platform_id = platform.id + AND ( + existing.model_name IN ('Seedream-5.0-Pro', 'doubao-seedream-5-0-pro-260628') + OR existing.provider_model_name = 'doubao-seedream-5-0-pro-260628' + OR existing.model_alias = 'Seedream-5.0-Pro' + ) + ) +ON CONFLICT (platform_id, model_name) DO UPDATE +SET base_model_id = EXCLUDED.base_model_id, + provider_model_name = EXCLUDED.provider_model_name, + model_alias = EXCLUDED.model_alias, + display_name = EXCLUDED.display_name, + model_type = EXCLUDED.model_type, + capabilities = EXCLUDED.capabilities, + pricing_mode = EXCLUDED.pricing_mode, + billing_config = EXCLUDED.billing_config, + retry_policy = EXCLUDED.retry_policy, + rate_limit_policy = EXCLUDED.rate_limit_policy, + enabled = EXCLUDED.enabled, + updated_at = now();