package plugins_test import ( "bytes" "fmt" "io" "maps" "net/http" "net/http/httptest" "slices" "strings" "testing" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/pkg/billingexpr" "github.com/QuantumNous/new-api/pkg/jsplugin" builtinplugins "github.com/QuantumNous/new-api/plugins" "github.com/QuantumNous/new-api/relay" taskplugin "github.com/QuantumNous/new-api/relay/channel/task/jsplugin" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const ( doubaoImageRoute = "/doubao/api/v3/images/generations" doubaoBaseURL = "https://ark.cn-beijing.volces.com" ) func TestDoubaoResponsesProtocol(t *testing.T) { testVideoResponsesProtocol(t, videoResponsesTestCase{ pluginKey: "doubao", model: "doubao-seedance-2-0-260128", requestBody: map[string]any{ "model": "doubao-seedance-2-0-260128", "input": []any{map[string]any{"role": "user", "content": []any{ map[string]any{"type": "input_text", "text": "a running fox"}, map[string]any{"type": "input_image", "image_url": "https://cdn.example/frame.png"}, }}}, "seconds": 6, "size": "1920x1080", }, wantAction: "image_to_video", wantRequest: map[string]any{ "model": "doubao-seedance-2-0-260128", "prompt": "a running fox", "images": []any{"https://cdn.example/frame.png"}, "seconds": float64(6), "metadata": map[string]any{ "resolution": "1080p", }, }, wantUsageKeys: []string{"resolution", "tokens", "video_input"}, wantVendorName: "doubao", }) } func newDoubaoPlugin(t *testing.T) (*jsplugin.Registry, *jsplugin.LoadedPlugin) { t.Helper() source, err := builtinplugins.Source("doubao") require.NoError(t, err) registry := jsplugin.NewRegistry() plugin, err := registry.RegisterFactory(source, jsplugin.Options{Key: "doubao"}) require.NoError(t, err) return registry, plugin } func decodeDoubaoImage(t *testing.T, registry *jsplugin.Registry, plugin *jsplugin.LoadedPlugin, body map[string]any) (map[string]any, error) { t.Helper() binding, found := registry.Generation().LookupDeclaredRoute(http.MethodPost, doubaoImageRoute) require.True(t, found) require.Equal(t, jsplugin.RouteTypeSubmit, binding.Route.Type) value, err := plugin.Engine.CallPath(t.Context(), "native", []string{binding.Route.Decode}, map[string]any{ "path": doubaoImageRoute, "body": map[string]any{"kind": "json", "value": body}, }) if err != nil { return nil, err } return alibabaObject(t, value), nil } // Runs the production host validation, body conversion and usage extraction // for one normalized image request without contacting Volcengine. func submitDoubaoImage(t *testing.T, plugin *jsplugin.LoadedPlugin, action string, request map[string]any) (map[string]any, map[string]any, string) { t.Helper() modelName := request["model"].(string) // RelayTask copies the decode action onto the relay info before the adaptor runs. info := &relaycommon.RelayInfo{ ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: doubaoBaseURL, UpstreamModelName: modelName}, OriginModelName: modelName, TaskRelayInfo: &relaycommon.TaskRelayInfo{PublicTaskID: "task_public", Action: action}, } adaptor := taskplugin.New(plugin) adaptor.Init(info) c, _ := gin.CreateTestContext(httptest.NewRecorder()) c.Request = httptest.NewRequest(http.MethodPost, doubaoImageRoute, nil) c.Set("task_request", request) require.Nil(t, adaptor.ValidateRequestAndSetAction(c, info)) reader, err := adaptor.BuildRequestBody(c, info) require.NoError(t, err) encoded, err := io.ReadAll(reader) require.NoError(t, err) var body map[string]any require.NoError(t, common.Unmarshal(encoded, &body)) facts, err := adaptor.ExtractUsageFactsValidated(c, info) require.NoError(t, err) url, err := adaptor.BuildRequestURL(info) require.NoError(t, err) return body, alibabaObject(t, facts), url } func TestDoubaoImageSubmission(t *testing.T) { registry, plugin := newDoubaoPlugin(t) reference := "https://cdn.example/reference.png" // Every model reports the four facts released with the shared image schema, // including those its own profile omits, so expressions saved before the // per-model profiles keep their charge. noImages := map[string]any{"images_up_to_1_5k": float64(0), "images_above_1_5k": float64(0), "input_images": float64(0), "layer_decomposition": false} facts := func(overrides map[string]any) map[string]any { merged := map[string]any{} maps.Copy(merged, noImages) maps.Copy(merged, overrides) return merged } for _, tc := range []struct { name string model string body map[string]any wantAction string wantBody map[string]any // upstream fields whose value differs from the request wantFacts map[string]any }{ {"text to image with a preset size", "doubao-seedream-4-0-250828", map[string]any{"prompt": "a cat", "size": "2K", "watermark": false, "response_format": "url", "stream": false, "seed": 42, "guidance_scale": 2.5}, "text_to_image", nil, facts(map[string]any{"images_above_1_5k": float64(1)})}, {"1K reserves the lower pricing tier", "doubao-seedream-4-0-250828", map[string]any{"prompt": "a cat", "size": "1K", "optimize_prompt_options": nil}, "text_to_image", nil, facts(map[string]any{"images_up_to_1_5k": float64(1)})}, {"lowercase presets are normalized", "doubao-seedream-4-0-250828", map[string]any{"prompt": "a cat", "size": "2k"}, "text_to_image", map[string]any{"size": "2K"}, facts(map[string]any{"images_above_1_5k": float64(1)})}, {"pixel sizes are normalized to WxH", "doubao-seedream-4-5-251128", map[string]any{"prompt": "a banner", "size": "3750 * 1250"}, "text_to_image", map[string]any{"size": "3750x1250"}, facts(map[string]any{"images_above_1_5k": float64(1)})}, {"the 2.61 megapixel boundary belongs to the lower tier", "doubao-seedream-5-0-pro-260628", map[string]any{"prompt": "a poster", "size": "1500X1740"}, "text_to_image", map[string]any{"size": "1500x1740"}, facts(map[string]any{"images_up_to_1_5k": float64(1)})}, {"5.0 pro 1.5K with fast prompt optimization", "doubao-seedream-5-0-pro-260628", map[string]any{"prompt": "a poster", "size": "1.5K", "optimize_prompt_options": map[string]any{"mode": "fast"}, "output_format": "png"}, "text_to_image", nil, facts(map[string]any{"images_up_to_1_5k": float64(1)})}, {"group generation estimates max_images and reference images", "doubao-seedream-5-0-lite-260128", map[string]any{"prompt": "a brand kit", "image": reference, "size": "2K", "sequential_image_generation": "auto", "sequential_image_generation_options": map[string]any{"max_images": 4}, "output_format": "png", "tools": []any{map[string]any{"type": "web_search"}}}, "image_to_image", nil, facts(map[string]any{"images_above_1_5k": float64(4), "input_images": float64(1)})}, {"group generation is capped by reference images", "doubao-seedream-4-5-251128", map[string]any{"prompt": "four seasons", "image": []any{reference, "https://cdn.example/second.png"}, "sequential_image_generation": "auto"}, "image_to_image", nil, facts(map[string]any{"images_above_1_5k": float64(13), "input_images": float64(2)})}, {"base64 references count as input images", "doubao-seedream-5-0-pro-260628", map[string]any{"prompt": "a cat", "image": []any{reference, "data:image/png;base64,iVBORw0KGgo="}, "size": "1K"}, "image_to_image", nil, facts(map[string]any{"images_up_to_1_5k": float64(1), "input_images": float64(2)})}, {"transparent background on 5.0 pro", "doubao-seedream-5-0-pro-260628", map[string]any{"prompt": "cut out the cat", "image": reference, "background": "transparent", "output_format": "png", "size": "1K"}, "image_to_image", nil, facts(map[string]any{"images_up_to_1_5k": float64(1), "input_images": float64(1)})}, {"background is forwarded to other models for upstream to decide", "doubao-seedream-5-0-lite-260128", map[string]any{"prompt": "cut out the cat", "background": "transparent", "size": "2K"}, "text_to_image", nil, facts(map[string]any{"images_above_1_5k": float64(1)})}, {"layer decomposition with auto size reserves every output at the higher tier", "doubao-seedream-5-0-pro-260628", map[string]any{"image": reference, "layer_decomposition": true, "size": "auto"}, "image_to_image", nil, facts(map[string]any{"images_above_1_5k": float64(17), "input_images": float64(1), "layer_decomposition": true})}, {"layer decomposition at 1K reserves the lower tier", "doubao-seedream-5-0-pro-260628", map[string]any{"image": reference, "layer_decomposition": true, "size": "1k"}, "image_to_image", map[string]any{"size": "1K"}, facts(map[string]any{"images_up_to_1_5k": float64(17), "input_images": float64(1), "layer_decomposition": true})}, {"5.0 flash layer decomposition at 1.5K", "doubao-seedream-5-0-flash-260915", map[string]any{"image": reference, "layer_decomposition": true, "size": "1.5K", "output_format": "png"}, "image_to_image", nil, facts(map[string]any{"images_up_to_1_5k": float64(17), "input_images": float64(1), "layer_decomposition": true})}, {"endpoint ids use the permissive profile", "ep-20260918-seedream", map[string]any{"prompt": "a cat", "size": "4K", "sequential_image_generation": "auto"}, "text_to_image", nil, facts(map[string]any{"images_above_1_5k": float64(15)})}, } { t.Run(tc.name, func(t *testing.T) { request := map[string]any{"model": tc.model} maps.Copy(request, tc.body) resolved, err := decodeDoubaoImage(t, registry, plugin, request) require.NoError(t, err) assert.Equal(t, tc.model, resolved["model"]) assert.Equal(t, tc.wantAction, resolved["action"]) body, facts, url := submitDoubaoImage(t, plugin, tc.wantAction, resolved["requestBody"].(map[string]any)) assert.Equal(t, doubaoBaseURL+"/api/v3/images/generations", url) assert.Equal(t, tc.model, body["model"]) assert.NotContains(t, body, "stream") for key, expected := range alibabaObject(t, tc.body) { if key == "stream" { continue } if override, ok := tc.wantBody[key]; ok { expected = override } assert.Contains(t, body, key) assert.Equal(t, expected, body[key], key) } assert.Equal(t, tc.wantFacts, facts) }) } // Legacy per-call pricing multiplies the price by every ratio, so the // reservation ratio is only the total requested output count: one 1K image // with two reference images reserves price × 1, not price × 1 × 2. t.Run("legacy per-call pricing reserves only the total output count", func(t *testing.T) { for _, tc := range []struct { name string model string body map[string]any want float64 }{ {"1K image with two reference images", "doubao-seedream-5-0-pro-260628", map[string]any{"prompt": "a cat", "image": []any{reference, "https://cdn.example/second.png"}, "size": "1K"}, 1}, {"group generation", "doubao-seedream-5-0-lite-260128", map[string]any{"prompt": "a brand kit", "image": reference, "size": "2K", "sequential_image_generation": "auto", "sequential_image_generation_options": map[string]any{"max_images": 4}}, 4}, {"layer decomposition", "doubao-seedream-5-0-pro-260628", map[string]any{"image": reference, "layer_decomposition": true, "size": "auto"}, 17}, } { request := map[string]any{"model": tc.model} maps.Copy(request, tc.body) resolved, err := decodeDoubaoImage(t, registry, plugin, request) require.NoError(t, err, tc.name) ctx := map[string]any{"upstreamModel": tc.model, "model": tc.model, "action": resolved["action"], "requestBody": resolved["requestBody"]} ctx["usagePurpose"] = "billing_ratios" value, err := plugin.Engine.Call(t.Context(), "extractUsage", ctx) require.NoError(t, err, tc.name) assert.Equal(t, map[string]any{"image_count": tc.want}, alibabaObject(t, value), tc.name) ctx["usagePurpose"] = "facts" value, err = plugin.Engine.Call(t.Context(), "extractUsage", ctx) require.NoError(t, err, tc.name) facts := alibabaObject(t, value) assert.NotContains(t, facts, "image_count", tc.name) assert.Equal(t, tc.want, facts["images_up_to_1_5k"].(float64)+facts["images_above_1_5k"].(float64), tc.name) } }) // Each model's pricing lists only the tiers it can output and layer // decomposition only where it is supported. t.Run("image models serve Responses and OpenAI Images host protocols", func(t *testing.T) { layered := []string{"images_up_to_1_5k", "images_above_1_5k", "input_images", "layer_decomposition"} oneTier := []string{"images_above_1_5k", "input_images"} for name, usageKeys := range map[string][]string{ "doubao-seedream-5-0-pro-260628": layered, "doubao-seedream-5-0-flash-260915": layered, "doubao-seedream-5-0-lite-260128": oneTier, "doubao-seedream-5-0-260128": oneTier, "doubao-seedream-4-5-251128": oneTier, "doubao-seedream-4-0-250828": {"images_up_to_1_5k", "images_above_1_5k", "input_images"}, } { _, found := registry.Generation().LookupEndpoint(http.MethodPost, "/v1/responses", name) assert.True(t, found, name) for _, path := range []string{"/v1/images/generations", "/v1/images/edits"} { binding, found := registry.Generation().LookupEndpoint(http.MethodPost, path, name) require.True(t, found, name+" "+path) assert.Equal(t, "openai_image", binding.Protocol) } _, found = registry.Generation().LookupEndpoint(http.MethodPost, "/v1/videos", name) assert.False(t, found, name) schema, examples := plugin.Meta.UsageForModel(name) assert.ElementsMatch(t, usageKeys, keysOf(schema), name) assert.NotEmpty(t, examples, name) } _, found := registry.Generation().LookupEndpoint(http.MethodPost, "/v1/videos", "doubao-seedance-2-0-260128") assert.True(t, found) _, found = registry.Generation().LookupEndpoint(http.MethodPost, "/v1/images/generations", "doubao-seedance-2-0-260128") assert.False(t, found) schema, _ := plugin.Meta.UsageForModel("doubao-seedance-2-0-260128") assert.ElementsMatch(t, []string{"tokens", "resolution", "video_input"}, keysOf(schema)) schema, examples := plugin.Meta.UsageForModel("doubao-seedance-1-5-pro-251215") assert.ElementsMatch(t, []string{"tokens", "resolution", "generate_audio"}, keysOf(schema)) assert.Equal(t, "boolean", schema["generate_audio"].Type) assert.NotEmpty(t, examples) }) } // Capability profiles follow the Ark model list: pricing lists only the // resolutions each Seedance model offers, reference video input only on // Seedance 2.x, and audio output only on Seedance 1.5 pro. func TestDoubaoSeedanceUsageFacts(t *testing.T) { _, plugin := newDoubaoPlugin(t) const ( pro10 = "doubao-seedance-1-0-pro-250528" pro15 = "doubao-seedance-1-5-pro-251215" v20 = "doubao-seedance-2-0-260128" fast20 = "doubao-seedance-2-0-fast-260128" mini20 = "doubao-seedance-2-0-mini-260615" v25 = "doubao-seedance-2-5-260628" ) families := []struct { models []string resolutions []string fields []string }{ {[]string{pro10, "doubao-seedance-1-0-lite-t2v", "doubao-seedance-1-0-lite-i2v"}, []string{"480p", "720p", "1080p"}, []string{"resolution", "tokens"}}, {[]string{pro15}, []string{"480p", "720p", "1080p"}, []string{"generate_audio", "resolution", "tokens"}}, {[]string{v20}, []string{"480p", "720p", "1080p", "4k"}, []string{"resolution", "tokens", "video_input"}}, {[]string{fast20, mini20}, []string{"480p", "720p"}, []string{"resolution", "tokens", "video_input"}}, {[]string{v25}, []string{"480p", "720p", "1080p"}, []string{"resolution", "tokens", "video_input"}}, } profiled := make([]string, 0, len(plugin.Meta.Models)) for _, family := range families { for _, name := range family.models { profiled = append(profiled, name) t.Run(name, func(t *testing.T) { schema, examples := plugin.Meta.UsageForModel(name) assert.Equal(t, family.fields, slices.Sorted(maps.Keys(schema))) assert.Equal(t, family.resolutions, schema["resolution"].Enum) require.NotEmpty(t, examples) for _, example := range examples { assert.Equal(t, family.fields, slices.Sorted(maps.Keys(example.Facts)), example.Label) assert.Contains(t, family.resolutions, example.Facts["resolution"], example.Label) } }) } } videoModels := make([]string, 0, len(profiled)) for _, name := range plugin.Meta.Models { if strings.Contains(name, "seedance") { videoModels = append(videoModels, name) } } assert.ElementsMatch(t, videoModels, profiled, "every Seedance model selects a capability profile") // A table price saved when every Seedance model declared video_input: its // last row is the unconditioned fallback. const sharedMatrix = `u("resolution") == "480p" && u("video_input") == "none" ? tier("480p·none", u("tokens") * 2 / 1000000) : ` + `u("resolution") == "480p" && u("video_input") == "video" ? tier("480p·video", u("tokens") * 3 / 1000000) : ` + `u("resolution") == "720p" && u("video_input") == "none" ? tier("720p·none", u("tokens") * 4 / 1000000) : ` + `u("resolution") == "720p" && u("video_input") == "video" ? tier("720p·video", u("tokens") * 5 / 1000000) : ` + `u("resolution") == "1080p" && u("video_input") == "none" ? tier("1080p·none", u("tokens") * 6 / 1000000) : ` + `u("resolution") == "1080p" && u("video_input") == "video" ? tier("1080p·video", u("tokens") * 7 / 1000000) : ` + `u("resolution") == "4k" && u("video_input") == "none" ? tier("4k·none", u("tokens") * 8 / 1000000) : ` + `tier("4k·video", u("tokens") * 9 / 1000000)` for _, tc := range []struct { name string model string request map[string]any want map[string]any wantErr string }{ {"1.5 pro defaults to audio", pro15, map[string]any{"metadata": map[string]any{"resolution": "720p"}}, map[string]any{"tokens": float64(108000), "resolution": "720p", "generate_audio": true, "video_input": "none"}, ""}, {"1.5 pro silent output", pro15, map[string]any{"metadata": map[string]any{"resolution": "720p", "generate_audio": false}}, map[string]any{"tokens": float64(108000), "resolution": "720p", "generate_audio": false, "video_input": "none"}, ""}, {"2.0 has no audio fact", v20, map[string]any{"metadata": map[string]any{"resolution": "720p", "generate_audio": false}}, map[string]any{"tokens": float64(108000), "resolution": "720p", "video_input": "none"}, ""}, {"2.0 offers 4k", v20, map[string]any{"metadata": map[string]any{"resolution": "4k"}}, map[string]any{"tokens": float64(972000), "resolution": "4k", "video_input": "none"}, ""}, {"1.0 pro reports no reference video although its profile omits it", pro10, map[string]any{"metadata": map[string]any{"resolution": "1080p"}}, map[string]any{"tokens": float64(243000), "resolution": "1080p", "video_input": "none"}, ""}, {"mini reserves its highest tier when the resolution is left to Ark", mini20, map[string]any{}, map[string]any{"tokens": float64(108000), "resolution": "720p", "video_input": "none"}, ""}, {"mini rejects 1080p", mini20, map[string]any{"metadata": map[string]any{"resolution": "1080p"}}, nil, mini20 + " resolution must be one of 480p, 720p"}, {"mini rejects a 1080p size", mini20, map[string]any{"size": "1920x1080"}, nil, mini20 + " resolution must be one of 480p, 720p"}, {"fast rejects 4k", fast20, map[string]any{"metadata": map[string]any{"resolution": "4k"}}, nil, fast20 + " resolution must be one of 480p, 720p"}, {"2.5 rejects 4k", v25, map[string]any{"metadata": map[string]any{"resolution": "4K"}}, nil, v25 + " resolution must be one of 480p, 720p, 1080p"}, } { t.Run(tc.name, func(t *testing.T) { request := map[string]any{"model": tc.model, "prompt": "a cat", "seconds": float64(5)} maps.Copy(request, tc.request) info := &relaycommon.RelayInfo{ ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: doubaoBaseURL, UpstreamModelName: tc.model}, OriginModelName: tc.model, TaskRelayInfo: &relaycommon.TaskRelayInfo{PublicTaskID: "task_public", Action: "text_to_video"}, } adaptor := taskplugin.New(plugin) adaptor.Init(info) c, _ := gin.CreateTestContext(httptest.NewRecorder()) c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", nil) c.Set("task_request", request) taskErr := adaptor.ValidateRequestAndSetAction(c, info) if tc.wantErr != "" { require.NotNil(t, taskErr) assert.Equal(t, http.StatusBadRequest, taskErr.StatusCode) assert.Contains(t, taskErr.Message, tc.wantErr) return } require.Nil(t, taskErr) reader, err := adaptor.BuildRequestBody(c, info) require.NoError(t, err) encoded, err := io.ReadAll(reader) require.NoError(t, err) var body map[string]any require.NoError(t, common.Unmarshal(encoded, &body)) metadata, _ := tc.request["metadata"].(map[string]any) assert.Equal(t, metadata["generate_audio"], body["generate_audio"]) facts, err := adaptor.ExtractUsageFactsValidated(c, info) require.NoError(t, err) assert.Equal(t, tc.want, alibabaObject(t, facts)) _, trace, err := billingexpr.RunExprWithRequest(sharedMatrix, billingexpr.TokenParams{}, billingexpr.RequestInput{Usage: facts}) require.NoError(t, err) assert.Equal(t, tc.want["resolution"].(string)+"·none", trace.MatchedTier, "saved table prices keep the no-video tier") }) } // Ark reads duration -1 as "the model picks the length" (Seedance 1.5 pro // and 2.x; 2.5 also defaults to it). The reservation covers the longest // output: 720p is 1280 × 720 × 24 / 1024 = 21,600 tokens per second. t.Run("duration -1 reaches Ark and reserves the longest duration", func(t *testing.T) { for _, tc := range []struct { name string model string request any wantDuration any wantTokens float64 }{ {"2.5 metadata on /v1/video/generations", v25, relaycommon.TaskSubmitReq{ Model: v25, Prompt: "a cat", Metadata: map[string]any{"duration": -1, "resolution": "720p"}, }, float64(-1), 30 * 21600}, {"2.5 without a duration keeps the Ark default", v25, map[string]any{ "model": v25, "prompt": "a cat", "metadata": map[string]any{"resolution": "720p"}, }, nil, 30 * 21600}, {"2.0 seconds from Responses or /v1/videos", v20, map[string]any{ "model": v20, "prompt": "a cat", "seconds": -1, "metadata": map[string]any{"resolution": "720p"}, }, float64(-1), 15 * 21600}, {"1.5 pro", pro15, map[string]any{ "model": pro15, "prompt": "a cat", "metadata": map[string]any{"duration": -1, "resolution": "720p"}, }, float64(-1), 12 * 21600}, } { t.Run(tc.name, func(t *testing.T) { info := &relaycommon.RelayInfo{ ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: doubaoBaseURL, UpstreamModelName: tc.model}, OriginModelName: tc.model, TaskRelayInfo: &relaycommon.TaskRelayInfo{PublicTaskID: "task_public", Action: "text_to_video"}, } adaptor := taskplugin.New(plugin) adaptor.Init(info) c, _ := gin.CreateTestContext(httptest.NewRecorder()) c.Request = httptest.NewRequest(http.MethodPost, "/v1/video/generations", nil) c.Set("task_request", tc.request) require.Nil(t, adaptor.ValidateRequestAndSetAction(c, info)) reader, err := adaptor.BuildRequestBody(c, info) require.NoError(t, err) encoded, err := io.ReadAll(reader) require.NoError(t, err) var body map[string]any require.NoError(t, common.Unmarshal(encoded, &body)) assert.Equal(t, tc.wantDuration, body["duration"]) facts, err := adaptor.ExtractUsageFactsValidated(c, info) require.NoError(t, err) assert.Equal(t, tc.wantTokens, facts["tokens"]) }) } decode := func(body map[string]any) (any, error) { return plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_video", "decodeRequest"}, map[string]any{ "model": v20, "body": map[string]any{"kind": "json", "value": body}, }) } value, err := decode(map[string]any{"model": v20, "prompt": "a cat", "duration": -1}) require.NoError(t, err) assert.Equal(t, float64(-1), alibabaObject(t, value)["requestBody"].(map[string]any)["seconds"]) _, err = decode(map[string]any{"model": v20, "prompt": "a cat", "seconds": -2}) require.ErrorContains(t, err, "seconds must be -1 or between 1 and 3600") }) t.Run("completion overlays only resolutions the model offers", func(t *testing.T) { for _, tc := range []struct { model string resolution string want map[string]any }{ {mini20, "1080p", map[string]any{"tokens": float64(90000)}}, {v25, "4k", map[string]any{"tokens": float64(90000)}}, {v20, "4k", map[string]any{"tokens": float64(90000), "resolution": "4k"}}, } { queryContext := map[string]any{"model": tc.model, "upstreamModel": tc.model, "action": "text_to_video"} body := map[string]any{ "status": "succeeded", "usage": map[string]any{"completion_tokens": 90000}, "content": map[string]any{"video_url": "https://cdn.example/video.mp4", "resolution": tc.resolution}, } value, err := plugin.Engine.Call(t.Context(), "extractUsageOnComplete", queryContext, map[string]any{"status": "SUCCESS"}, body) require.NoError(t, err, tc.model) assert.Equal(t, tc.want, alibabaObject(t, value), tc.model) } }) } func keysOf(schema map[string]jsplugin.UsageFieldSchema) []string { keys := make([]string, 0, len(schema)) for key := range schema { keys = append(keys, key) } return keys } // Rejections mirror the Ark API reference; the model-capability cases were // confirmed against live 400 responses (2026-09). func TestDoubaoImageValidation(t *testing.T) { registry, plugin := newDoubaoPlugin(t) const pro, flash, lite, v45, v40 = "doubao-seedream-5-0-pro-260628", "doubao-seedream-5-0-flash-260915", "doubao-seedream-5-0-lite-260128", "doubao-seedream-4-5-251128", "doubao-seedream-4-0-250828" reference := "https://cdn.example/1.png" manyReferences := make([]any, 0, 11) for index := range 11 { manyReferences = append(manyReferences, fmt.Sprintf("https://cdn.example/%d.png", index)) } for _, tc := range []struct { name string model string body map[string]any wantErr string }{ {"prompt is required", v40, map[string]any{}, "prompt is required"}, {"client streaming is rejected", v40, map[string]any{"prompt": "a cat", "stream": true}, "stream is not supported"}, {"base64 responses are not deliverable", v40, map[string]any{"prompt": "a cat", "response_format": "b64_json"}, "response_format must be url"}, {"5.0 pro rejects group generation", pro, map[string]any{"prompt": "a cat", "sequential_image_generation": "auto"}, "sequential_image_generation is not supported"}, {"5.0 pro rejects sequential_image_generation even when disabled", pro, map[string]any{"prompt": "a cat", "sequential_image_generation": "disabled"}, "sequential_image_generation is not supported"}, {"5.0 flash rejects group generation", flash, map[string]any{"prompt": "a cat", "sequential_image_generation": "auto"}, "sequential_image_generation is not supported"}, {"sequential_image_generation is an enum", lite, map[string]any{"prompt": "a cat", "sequential_image_generation": "on"}, "must be auto or disabled"}, {"sequential options must be an object", lite, map[string]any{"prompt": "a cat", "sequential_image_generation": "auto", "sequential_image_generation_options": 3}, "sequential_image_generation_options must be an object"}, {"max_images is bounded", lite, map[string]any{"prompt": "a cat", "sequential_image_generation": "auto", "sequential_image_generation_options": map[string]any{"max_images": 16}}, "max_images must be an integer between 1 and 15"}, {"reference images are bounded", pro, map[string]any{"prompt": "a cat", "image": manyReferences}, "at most 10 reference images"}, {"fast prompt optimization is unsupported on 5.0 flash", flash, map[string]any{"prompt": "a cat", "optimize_prompt_options": map[string]any{"mode": "fast"}}, "mode fast is not supported"}, {"fast prompt optimization is unsupported on 5.0 lite", lite, map[string]any{"prompt": "a cat", "optimize_prompt_options": map[string]any{"mode": "fast"}}, "mode fast is not supported"}, {"fast prompt optimization is unsupported on 4.5", v45, map[string]any{"prompt": "a cat", "optimize_prompt_options": map[string]any{"mode": "fast"}}, "mode fast is not supported"}, {"prompt optimization mode is an enum", v40, map[string]any{"prompt": "a cat", "optimize_prompt_options": map[string]any{"mode": "turbo"}}, "must be standard or fast"}, {"prompt optimization options must be an object", v40, map[string]any{"prompt": "a cat", "optimize_prompt_options": 123}, "optimize_prompt_options must be an object"}, {"output_format is an enum", pro, map[string]any{"prompt": "a cat", "output_format": "webp"}, "output_format must be png or jpeg"}, {"output_format follows the model", v40, map[string]any{"prompt": "a cat", "output_format": "png"}, "output_format is not supported"}, {"background is a string enum", pro, map[string]any{"prompt": "a cat", "image": reference, "background": true}, "background must be opaque or transparent"}, {"transparent background needs exactly one input image", pro, map[string]any{"prompt": "a cat", "background": "transparent"}, "requires exactly one input image"}, {"transparent background rejects jpeg output", pro, map[string]any{"prompt": "a cat", "image": reference, "background": "transparent", "output_format": "jpeg"}, "cannot be combined with output_format jpeg"}, {"tools must be objects with a type", lite, map[string]any{"prompt": "a cat", "tools": []any{map[string]any{}}}, "tools must be an array of objects with a string type"}, {"tools must be an array", lite, map[string]any{"prompt": "a cat", "tools": map[string]any{"type": "web_search"}}, "tools must be an array of objects with a string type"}, {"tools follow the model", pro, map[string]any{"prompt": "a cat", "tools": []any{map[string]any{"type": "web_search"}}}, "tools are not supported"}, {"seed must be an integer", v40, map[string]any{"prompt": "a cat", "seed": "abc"}, "seed must be an integer"}, {"seed rejects fractions", v40, map[string]any{"prompt": "a cat", "seed": 1.5}, "seed must be an integer"}, {"guidance_scale must be a number", v40, map[string]any{"prompt": "a cat", "guidance_scale": "x"}, "guidance_scale must be a number"}, {"size must be a string", v40, map[string]any{"prompt": "a cat", "size": 2048}, "size must be a string"}, {"presets follow the model", v45, map[string]any{"prompt": "a cat", "size": "1K"}, "size must be one of 2K, 4K"}, {"5.0 flash tops out at 2K", flash, map[string]any{"prompt": "a cat", "size": "4K"}, "size must be one of 1K, 1.5K, 2K"}, {"pixel sizes follow the model", v45, map[string]any{"prompt": "a cat", "size": "1500x1500"}, "outside the model's pixel"}, {"aspect ratios are bounded", v40, map[string]any{"prompt": "a cat", "size": "8192x480"}, "outside the model's pixel"}, {"layer decomposition needs one image", pro, map[string]any{"layer_decomposition": true}, "requires exactly one input image"}, {"layer decomposition follows the model", v40, map[string]any{"prompt": "a cat", "image": reference, "layer_decomposition": true}, "layer_decomposition is not supported"}, {"layer decomposition sizes are presets", pro, map[string]any{"image": reference, "layer_decomposition": true, "size": "2048x2048"}, "layer decomposition sizes must be one of"}, {"auto size needs layer decomposition", v40, map[string]any{"prompt": "a cat", "size": "auto"}, "size auto is only supported"}, {"image references must be URLs", v40, map[string]any{"prompt": "a cat", "image": "ftp://cdn.example/1.png"}, "image must be an HTTP URL"}, {"base64 references need the full data URI", v40, map[string]any{"prompt": "a cat", "image": "data:image/png,iVBORw0KGgo="}, "image must be an HTTP URL"}, {"base64 formats are lowercase", v40, map[string]any{"prompt": "a cat", "image": "data:image/PNG;base64,iVBORw0KGgo="}, "image must be an HTTP URL"}, } { t.Run(tc.name, func(t *testing.T) { request := map[string]any{"model": tc.model} maps.Copy(request, tc.body) _, err := decodeDoubaoImage(t, registry, plugin, request) require.ErrorContains(t, err, tc.wantErr) }) } } // Fixtures mirror real Ark responses captured on 2026-09-18 (signed URLs replaced). func TestDoubaoImageResults(t *testing.T) { _, plugin := newDoubaoPlugin(t) const lite, pro = "doubao-seedream-5-0-lite-260128", "doubao-seedream-5-0-pro-260628" first, second := "https://ark-content.example/1.jpeg?sig=secret", "https://ark-content.example/2.jpeg?sig=secret" // 5.0 lite group of two plus one failed member; Ark echoes the canonical model name. groupBody := map[string]any{ "model": "doubao-seedream-5-0-260128", "created": 1789733151, "data": []any{ map[string]any{"url": first, "size": "2848x1600"}, map[string]any{"url": second, "size": "2848x1600"}, map[string]any{"error": map[string]any{"code": "OutputImageSensitiveContentDetected", "message": "blocked"}}, }, "usage": map[string]any{"generated_images": 2, "output_tokens": 35600, "total_tokens": 35600}, } // 5.0 pro layer decomposition with size auto: base and first layer above 2.61 MP, second layer below. layerBody := map[string]any{ "model": pro, "created": 1789733460, "data": []any{ map[string]any{"url": "https://ark-content.example/base.jpeg?sig=secret", "size": "2848x1600", "output_format": "jpeg", "z_index": 0}, map[string]any{"url": "https://ark-content.example/layer1.png?sig=secret", "size": "2848x1600", "output_format": "png", "z_index": 1, "name": "背景场景", "description": "室内木桌背景", "bounding_box": map[string]any{"absolute": []any{0, 0, 2848, 1600}, "normalized": []any{0, 0, 1000, 999}}}, map[string]any{"url": "https://ark-content.example/layer2.png?sig=secret", "size": "1125x956", "output_format": "png", "z_index": 2, "name": "主体橘猫", "description": "坐姿橘猫", "bounding_box": map[string]any{"absolute": []any{955, 622, 2080, 1578}, "normalized": []any{335, 389, 730, 986}}}, }, "usage": map[string]any{"input_images": 1, "generated_images": 3, "output_tokens": 42316, "total_tokens": 42316}, } // 5.0 pro single 1K image from two reference images. referenceBody := map[string]any{ "model": pro, "created": 1789733390, "data": []any{map[string]any{"url": first, "size": "1424x800", "output_format": "jpeg"}}, "usage": map[string]any{"input_images": 2, "generated_images": 1, "output_tokens": 4450, "total_tokens": 4450}, } parseResponse := func(t *testing.T, model string, request map[string]any, payload map[string]any) *relaycommon.TaskInfo { t.Helper() info := &relaycommon.RelayInfo{OriginModelName: model, ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: model, ChannelBaseUrl: doubaoBaseURL}, TaskRelayInfo: &relaycommon.TaskRelayInfo{PublicTaskID: "task_public"}} adaptor := taskplugin.New(plugin) adaptor.Init(info) c, _ := gin.CreateTestContext(httptest.NewRecorder()) c.Request = httptest.NewRequest(http.MethodPost, doubaoImageRoute, nil) c.Set("task_request", request) encoded, err := common.Marshal(payload) require.NoError(t, err) parsed, taskErr := adaptor.ParseResponse(c, &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(bytes.NewReader(encoded))}, info) require.Nil(t, taskErr) require.NotNil(t, parsed.Immediate) assert.Equal(t, "task_public", parsed.UpstreamTaskID) assert.JSONEq(t, string(encoded), string(parsed.TaskData)) return parsed.Immediate } groupRequest := map[string]any{"model": lite, "prompt": "a cat", "sequential_image_generation": "auto"} queryContext := map[string]any{"upstreamModel": lite, "model": lite, "action": "text_to_image"} proQuery := map[string]any{"upstreamModel": pro, "model": pro, "action": "text_to_image"} t.Run("a single-tier group settles every delivered image", func(t *testing.T) { immediate := parseResponse(t, lite, groupRequest, groupBody) assert.Equal(t, "SUCCESS", immediate.Status) assert.Equal(t, "100%", immediate.Progress) assert.Equal(t, first, immediate.Url) assert.Equal(t, map[string]any{"images_above_1_5k": float64(2)}, immediate.UsageFacts) value, err := plugin.Engine.CallPath(t.Context(), "native", []string{"imageCreated"}, map[string]any{}, map[string]any{"task_id": "task_public", "status": "SUCCESS", "data": groupBody}) require.NoError(t, err) assert.Equal(t, alibabaObject(t, groupBody), alibabaObject(t, value)) value, err = plugin.Engine.Call(t.Context(), "extractUsageOnSubmit", queryContext, groupBody) require.NoError(t, err) assert.Equal(t, map[string]any{"image_count": float64(2)}, alibabaObject(t, value), "legacy per-call pricing settles on the delivered count") }) // Legacy per-call pricing multiplies the price by every ratio the plugin // returns, so the submit-time settlement carries only the total delivered // output count; a mixed-tier result with a reference image must settle 5 // images, not price × 4 × 1 × 1. t.Run("legacy per-call pricing settles the total output count", func(t *testing.T) { tieredBody := map[string]any{ "model": pro, "data": []any{ map[string]any{"url": first, "size": "1024x1024"}, map[string]any{"url": second, "size": "1024x1024"}, map[string]any{"url": first, "size": "1024x1024"}, map[string]any{"url": second, "size": "1024x1024"}, map[string]any{"url": first, "size": "2848x1600"}, }, "usage": map[string]any{"input_images": 1, "generated_images": 5}, } value, err := plugin.Engine.Call(t.Context(), "extractUsageOnComplete", proQuery, map[string]any{"status": "SUCCESS"}, tieredBody) require.NoError(t, err) assert.Equal(t, map[string]any{"images_up_to_1_5k": float64(4), "images_above_1_5k": float64(1), "input_images": float64(1)}, alibabaObject(t, value), "task expressions keep the tiered facts") for _, tc := range []struct { name string body map[string]any want float64 }{ {"mixed tiers with a reference image", tieredBody, 5}, {"layer decomposition", layerBody, 3}, {"single image from two reference images", referenceBody, 1}, } { value, err := plugin.Engine.Call(t.Context(), "extractUsageOnSubmit", queryContext, tc.body) require.NoError(t, err, tc.name) assert.Equal(t, map[string]any{"image_count": tc.want}, alibabaObject(t, value), tc.name) } value, err = plugin.Engine.Call(t.Context(), "extractUsageOnSubmit", queryContext, map[string]any{"data": []any{map[string]any{"error": map[string]any{"code": "x"}}}}) require.NoError(t, err) assert.Empty(t, alibabaObject(t, value), "no delivered image leaves the reservation in place") }) t.Run("mixed-tier layers settle per layer with the input image count", func(t *testing.T) { request := map[string]any{"model": pro, "image": "https://cdn.example/photo.png", "layer_decomposition": true, "size": "auto"} immediate := parseResponse(t, pro, request, layerBody) assert.Equal(t, "SUCCESS", immediate.Status) assert.Equal(t, map[string]any{"images_up_to_1_5k": float64(1), "images_above_1_5k": float64(2), "input_images": float64(1)}, immediate.UsageFacts) }) t.Run("reference images settle from usage.input_images", func(t *testing.T) { request := map[string]any{"model": pro, "prompt": "a cat", "image": []any{"https://cdn.example/a.png", "https://cdn.example/b.png", "https://cdn.example/c.png"}, "size": "1K"} immediate := parseResponse(t, pro, request, referenceBody) assert.Equal(t, map[string]any{"images_up_to_1_5k": float64(1), "images_above_1_5k": float64(0), "input_images": float64(2)}, immediate.UsageFacts) }) t.Run("invalid completion counts retain the reservation", func(t *testing.T) { for _, count := range []any{-1, 0, 1.5, 99, "2", 3} { payload := map[string]any{"data": []any{map[string]any{"url": first, "size": "2048x2048"}}, "usage": map[string]any{"generated_images": count}} immediate := parseResponse(t, lite, groupRequest, payload) assert.Equal(t, "SUCCESS", immediate.Status) assert.Nil(t, immediate.UsageFacts, "count %v must not replace the reservation", count) } }) t.Run("invalid input image counts keep the estimate while tiers settle", func(t *testing.T) { for _, count := range []any{-1, 1.5, 15, "2"} { payload := map[string]any{"data": []any{map[string]any{"url": first, "size": "1424x800"}}, "usage": map[string]any{"generated_images": 1, "input_images": count}} value, err := plugin.Engine.Call(t.Context(), "extractUsageOnComplete", proQuery, map[string]any{"status": "SUCCESS"}, payload) require.NoError(t, err) assert.Equal(t, map[string]any{"images_up_to_1_5k": float64(1), "images_above_1_5k": float64(0)}, alibabaObject(t, value), "input_images %v", count) } }) t.Run("missing sizes keep the estimated tiers", func(t *testing.T) { payload := map[string]any{"data": []any{map[string]any{"url": first, "size": "2048x2048"}, map[string]any{"url": second}}, "usage": map[string]any{"generated_images": 2, "input_images": 1}} value, err := plugin.Engine.Call(t.Context(), "extractUsageOnComplete", proQuery, map[string]any{"status": "SUCCESS"}, payload) require.NoError(t, err) assert.Equal(t, map[string]any{"input_images": float64(1)}, alibabaObject(t, value)) // A single-tier model needs no size to know the tier. value, err = plugin.Engine.Call(t.Context(), "extractUsageOnComplete", queryContext, map[string]any{"status": "SUCCESS"}, payload) require.NoError(t, err) assert.Equal(t, map[string]any{"images_above_1_5k": float64(2), "input_images": float64(1)}, alibabaObject(t, value)) }) t.Run("missing usage settles from image payload sizes", func(t *testing.T) { payload := map[string]any{"data": []any{map[string]any{"url": first, "size": "2848x1600"}, map[string]any{"url": second, "size": "1024x1024"}, map[string]any{"error": map[string]any{"code": "x"}}}} value, err := plugin.Engine.Call(t.Context(), "extractUsageOnComplete", proQuery, map[string]any{"status": "SUCCESS"}, payload) require.NoError(t, err) assert.Equal(t, map[string]any{"images_up_to_1_5k": float64(1), "images_above_1_5k": float64(1)}, alibabaObject(t, value)) }) t.Run("failed and empty results do not complete a task", func(t *testing.T) { for _, payload := range []map[string]any{ {"error": map[string]any{"code": "InvalidParameter", "message": "The parameter `seed` specified in the request is not valid. Request id: 0217", "param": "", "type": ""}}, {"data": []any{map[string]any{"error": map[string]any{"code": "OutputImageSensitiveContentDetected", "message": "blocked"}}}, "usage": map[string]any{"generated_images": 0}}, {"data": []any{}}, } { _, err := plugin.Engine.Call(t.Context(), "parseSubmitResponse", map[string]any{"upstreamModel": lite, "publicTaskId": "task_public", "requestBody": map[string]any{"model": lite, "prompt": "a cat"}}, map[string]any{"statusCode": 200, "body": payload}) require.Error(t, err) } }) t.Run("artifacts and Responses output use gateway URLs", func(t *testing.T) { value, err := plugin.Engine.Call(t.Context(), "listArtifacts", map[string]any{"taskId": "task_public", "status": "SUCCESS", "action": "text_to_image", "data": groupBody}) require.NoError(t, err) encoded, err := common.Marshal(value) require.NoError(t, err) var artifacts []map[string]any require.NoError(t, common.Unmarshal(encoded, &artifacts)) require.Len(t, artifacts, 2) assert.Equal(t, "image-1", artifacts[0]["key"]) assert.Equal(t, "image", artifacts[0]["type"]) assert.Equal(t, "image-2", artifacts[1]["key"]) value, err = plugin.Engine.Call(t.Context(), "buildContentRequest", map[string]any{"artifactKey": "image-2", "action": "text_to_image", "data": groupBody, "clientRequest": map[string]any{"method": "GET"}}) require.NoError(t, err) content := alibabaObject(t, value) assert.Equal(t, second, content["url"]) assert.Equal(t, true, content["credentialless"]) gateway := map[string]any{ "model": lite, "artifacts": map[string]any{ "image-1": map[string]any{"key": "image-1", "type": "image", "url": "https://gateway.example/v1/tasks/task_public/artifacts/image-1/content?access=a"}, "image-2": map[string]any{"key": "image-2", "type": "image", "url": "https://gateway.example/v1/tasks/task_public/artifacts/image-2/content?access=b"}, }, } task := map[string]any{"task_id": "task_public", "status": "SUCCESS", "progress": "100%", "created_at": 10, "updated_at": 20, "data": groupBody} value, err = plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "renderFinal"}, gateway, task) require.NoError(t, err) machine := relay.NewPluginResponsesMachine("task_public", lite, 10, relay.DefaultPluginProtocolLimits()) response, err := machine.FinalResponse(value, "SUCCESS") require.NoError(t, err) text := response["output"].([]any)[0].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string) assert.Contains(t, text, "artifacts/image-1/content") assert.Contains(t, text, "artifacts/image-2/content") assert.NotContains(t, text, "ark-content.example") _, err = plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "renderFinal"}, map[string]any{"model": lite}, task) require.ErrorContains(t, err, "image artifact is unavailable") }) } func TestDoubaoImageResponsesDecode(t *testing.T) { _, plugin := newDoubaoPlugin(t) const model = "doubao-seedream-5-0-lite-260128" decode := func(t *testing.T, body map[string]any) (map[string]any, error) { t.Helper() value, err := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "decodeRequest"}, map[string]any{ "model": body["model"], "stream": false, "body": map[string]any{"kind": "json", "value": body}, }) if err != nil { return nil, err } return alibabaObject(t, value), nil } resolved, err := decode(t, map[string]any{ "model": model, "input": []any{map[string]any{"role": "user", "content": []any{ map[string]any{"type": "input_text", "text": "a poster"}, map[string]any{"type": "input_image", "image_url": "https://cdn.example/ref.png"}, }}}, "size": "2K", "sequential_image_generation": "auto", "sequential_image_generation_options": map[string]any{"max_images": 3}, "watermark": false, // Host-owned background execution flag: must never become Ark's transparency option. "background": true, // Only Ark's web_search tool is forwarded, stripped to its type; function tools are dropped. "tools": []any{ map[string]any{"type": "web_search", "search_context_size": "low"}, map[string]any{"type": "function", "name": "lookup", "parameters": map[string]any{}}, }, "metadata": map[string]any{"ignored": true}, }) require.NoError(t, err) assert.Equal(t, model, resolved["model"]) assert.Equal(t, "image_to_image", resolved["action"]) request := resolved["requestBody"].(map[string]any) assert.Equal(t, map[string]any{ "model": model, "prompt": "a poster", "image": []any{"https://cdn.example/ref.png"}, "size": "2K", "sequential_image_generation": "auto", "sequential_image_generation_options": map[string]any{"max_images": float64(3)}, "watermark": false, "tools": []any{map[string]any{"type": "web_search"}}, }, request) body, facts, url := submitDoubaoImage(t, plugin, "image_to_image", request) assert.Equal(t, doubaoBaseURL+"/api/v3/images/generations", url) assert.Equal(t, []any{"https://cdn.example/ref.png"}, body["image"]) assert.NotContains(t, body, "background") assert.Equal(t, map[string]any{"images_up_to_1_5k": float64(0), "images_above_1_5k": float64(3), "input_images": float64(1), "layer_decomposition": false}, facts) t.Run("function-only tools and background never reach a model without tool support", func(t *testing.T) { resolved, err := decode(t, map[string]any{"model": "doubao-seedream-4-0-250828", "input": "a cat", "background": true, "tools": []any{map[string]any{"type": "function", "name": "lookup"}}}) require.NoError(t, err) assert.Equal(t, map[string]any{"model": "doubao-seedream-4-0-250828", "prompt": "a cat"}, resolved["requestBody"]) }) _, err = decode(t, map[string]any{"model": model, "input": "a cat", "response_format": "b64_json"}) require.ErrorContains(t, err, "response_format must be url") } func decodeDoubaoOpenAIImage(t *testing.T, plugin *jsplugin.LoadedPlugin, operation, model string, body map[string]any) (map[string]any, error) { t.Helper() path := "/v1/images/generations" if operation == "edit" { path = "/v1/images/edits" } value, err := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_image", "decodeRequest"}, map[string]any{ "protocol": "openai_image", "operation": operation, "model": model, "path": path, "method": http.MethodPost, "body": body, }) if err != nil { return nil, err } return alibabaObject(t, value), nil } // OpenAI Images requests reach Ark with the same body, action and usage facts // as the equivalent native request. Host-owned and OpenAI-only fields are not // forwarded, and the facts never gain an image_count. func TestDoubaoOpenAIImageProtocol(t *testing.T) { registry, plugin := newDoubaoPlugin(t) const pro, lite, v40 = "doubao-seedream-5-0-pro-260628", "doubao-seedream-5-0-lite-260128", "doubao-seedream-4-0-250828" reference := "https://cdn.example/reference.png" facts := func(lower, higher, inputs float64, layered bool) map[string]any { return map[string]any{"images_up_to_1_5k": lower, "images_above_1_5k": higher, "input_images": inputs, "layer_decomposition": layered} } for _, tc := range []struct { name string model string operation string native map[string]any // the equivalent native route body extra map[string]any // OpenAI fields accepted but not forwarded wantFacts map[string]any }{ {"text to image", v40, "generate", map[string]any{"prompt": "a cat", "size": "2K", "seed": 42, "watermark": false}, map[string]any{"n": 1, "response_format": "b64_json", "quality": "high", "user": "u1"}, facts(0, 1, 0, false)}, {"1K reserves the lower tier", pro, "generate", map[string]any{"prompt": "a cat", "size": "1K", "output_format": "png"}, map[string]any{"response_format": "url", "style": "vivid", "stream": false}, facts(1, 0, 0, false)}, {"group generation with a reference image", lite, "edit", map[string]any{"prompt": "a brand kit", "image": reference, "size": "2K", "sequential_image_generation": "auto", "sequential_image_generation_options": map[string]any{"max_images": 4}, "tools": []any{map[string]any{"type": "web_search"}}}, map[string]any{"n": nil}, facts(0, 4, 1, false)}, {"layer decomposition", pro, "edit", map[string]any{"image": []any{reference}, "layer_decomposition": true, "size": "auto"}, nil, facts(0, 17, 1, true)}, } { t.Run(tc.name, func(t *testing.T) { nativeBody := map[string]any{"model": tc.model} maps.Copy(nativeBody, tc.native) native, err := decodeDoubaoImage(t, registry, plugin, nativeBody) require.NoError(t, err) openaiBody := map[string]any{"model": tc.model} maps.Copy(openaiBody, tc.native) maps.Copy(openaiBody, tc.extra) resolved, err := decodeDoubaoOpenAIImage(t, plugin, tc.operation, tc.model, map[string]any{"kind": "json", "value": openaiBody}) require.NoError(t, err) assert.Equal(t, tc.model, resolved["model"]) assert.Equal(t, native["action"], resolved["action"]) assert.Equal(t, native["requestBody"], resolved["requestBody"]) body, facts, url := submitDoubaoImage(t, plugin, resolved["action"].(string), resolved["requestBody"].(map[string]any)) assert.Equal(t, doubaoBaseURL+"/api/v3/images/generations", url) for key := range tc.extra { assert.NotContains(t, body, key) } assert.Equal(t, tc.wantFacts, facts) }) } t.Run("multipart edits send uploads as Base64 data URLs", func(t *testing.T) { resolved, err := decodeDoubaoOpenAIImage(t, plugin, "edit", lite, map[string]any{"kind": "multipart", "fields": map[string][]string{ "model": {lite}, "prompt": {"merge the two"}, "image": {reference}, "size": {"2K"}, "n": {"1"}, "response_format": {"b64_json"}, "watermark": {"false"}, "seed": {"7"}, "sequential_image_generation": {"auto"}, "sequential_image_generation_options": {`{"max_images":2}`}, }, "files": []any{ map[string]any{"ref": "request_file:image[]", "field": "image[]", "filename": "a.png", "mimeType": "image/png", "size": 10}, map[string]any{"ref": "request_file:image[]#1", "field": "image[]", "filename": "b.jpg", "mimeType": "IMAGE/JPEG", "size": 10}, }, }) require.NoError(t, err) assert.Equal(t, "image_to_image", resolved["action"]) images := []any{ reference, map[string]any{"__fileRef": "request_file:image[]", "encoding": "dataUrl", "mimeType": "image/png", "maxBytes": float64(31457280)}, map[string]any{"__fileRef": "request_file:image[]#1", "encoding": "dataUrl", "mimeType": "image/jpeg", "maxBytes": float64(31457280)}, } assert.Equal(t, map[string]any{ "model": lite, "prompt": "merge the two", "image": images, "size": "2K", "watermark": false, "seed": float64(7), "sequential_image_generation": "auto", "sequential_image_generation_options": map[string]any{"max_images": float64(2)}, }, resolved["requestBody"]) driver := map[string]any{"upstreamModel": lite, "model": lite, "action": "image_to_image", "requestBody": resolved["requestBody"], "baseUrl": doubaoBaseURL, "apiKey": "k", "usagePurpose": "facts"} value, err := plugin.Engine.Call(t.Context(), "buildSubmitRequest", driver) require.NoError(t, err) assert.Equal(t, images, alibabaObject(t, value)["body"].(map[string]any)["image"], "placeholders reach the upstream body for the host to inline") value, err = plugin.Engine.Call(t.Context(), "extractUsage", driver) require.NoError(t, err) assert.Equal(t, facts(0, 2, 3, false), alibabaObject(t, value)) driver["usagePurpose"] = "billing_ratios" value, err = plugin.Engine.Call(t.Context(), "extractUsage", driver) require.NoError(t, err) assert.Equal(t, map[string]any{"image_count": float64(2)}, alibabaObject(t, value)) }) t.Run("requests Ark cannot honor are rejected before billing", func(t *testing.T) { for _, tc := range []struct { name string model string operation string body map[string]any wantErr string }{ {"counts other than one", v40, "generate", map[string]any{"kind": "json", "value": map[string]any{"prompt": "a cat", "n": 2}}, "n must be 1"}, {"multipart counts other than one", lite, "edit", map[string]any{"kind": "multipart", "fields": map[string][]string{"prompt": {"a cat"}, "image": {reference}, "n": {"4"}}}, "n must be 1"}, {"streaming", v40, "generate", map[string]any{"kind": "json", "value": map[string]any{"prompt": "a cat", "stream": true}}, "stream is not supported"}, {"unknown response formats", v40, "generate", map[string]any{"kind": "json", "value": map[string]any{"prompt": "a cat", "response_format": "base64"}}, "response_format must be url or b64_json"}, {"edits without an image", v40, "edit", map[string]any{"kind": "json", "value": map[string]any{"prompt": "a cat"}}, "image is required"}, {"masks", v40, "edit", map[string]any{"kind": "json", "value": map[string]any{"prompt": "a cat", "image": reference, "mask": reference}}, "mask is not supported"}, {"mask uploads", v40, "edit", map[string]any{"kind": "multipart", "fields": map[string][]string{"prompt": {"a cat"}}, "files": []any{ map[string]any{"ref": "request_file:image", "field": "image", "filename": "a.png", "mimeType": "image/png", "size": 10}, map[string]any{"ref": "request_file:mask", "field": "mask", "filename": "m.png", "mimeType": "image/png", "size": 10}, }}, "mask is not supported"}, {"non-boolean multipart flags", v40, "edit", map[string]any{"kind": "multipart", "fields": map[string][]string{"prompt": {"a cat"}, "image": {reference}, "watermark": {"yes"}}}, "watermark must be true or false"}, {"model capabilities", pro, "generate", map[string]any{"kind": "json", "value": map[string]any{"prompt": "a cat", "sequential_image_generation": "auto"}}, "sequential_image_generation is not supported"}, } { _, err := decodeDoubaoOpenAIImage(t, plugin, tc.operation, tc.model, tc.body) require.ErrorContains(t, err, tc.wantErr, tc.name) } }) t.Run("render returns the delivered image URLs", func(t *testing.T) { first, second := "https://ark-content.example/1.jpeg?sig=secret", "https://ark-content.example/2.jpeg?sig=secret" body := map[string]any{ "model": lite, "created": 1789733151, "data": []any{ map[string]any{"url": first, "size": "2848x1600"}, map[string]any{"error": map[string]any{"code": "OutputImageSensitiveContentDetected", "message": "blocked"}}, map[string]any{"url": second, "size": "2848x1600"}, }, "usage": map[string]any{"generated_images": 2}, } value, err := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_image", "render"}, map[string]any{"model": lite}, map[string]any{"task_id": "task_public", "status": "SUCCESS", "created_at": 1789733150, "data": body}) require.NoError(t, err) assert.Equal(t, map[string]any{"created": float64(1789733150), "data": []any{map[string]any{"url": first}, map[string]any{"url": second}}}, alibabaObject(t, value)) }) } // Channel model mapping may send a declared Seedream model to an Ark endpoint ID. // The declared model still drives capability checks and the image usage profile. func TestDoubaoImageEndpointMappingKeepsDeclaredModel(t *testing.T) { _, plugin := newDoubaoPlugin(t) const declared, endpoint = "doubao-seedream-5-0-pro-260628", "ep-20260918-seedream" for _, tc := range []struct { name string body map[string]any wantErr string }{ {"declared capabilities still apply", map[string]any{"prompt": "a cat", "sequential_image_generation": "auto"}, "sequential_image_generation is not supported"}, {"the endpoint receives the request", map[string]any{"prompt": "a cat", "size": "2K"}, ""}, } { t.Run(tc.name, func(t *testing.T) { request := map[string]any{"model": declared} maps.Copy(request, tc.body) info := &relaycommon.RelayInfo{ ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: doubaoBaseURL, UpstreamModelName: endpoint}, OriginModelName: declared, TaskRelayInfo: &relaycommon.TaskRelayInfo{PublicTaskID: "task_public", Action: "text_to_image"}, } adaptor := taskplugin.New(plugin) adaptor.Init(info) c, _ := gin.CreateTestContext(httptest.NewRecorder()) c.Request = httptest.NewRequest(http.MethodPost, doubaoImageRoute, nil) c.Set("task_request", request) taskErr := adaptor.ValidateRequestAndSetAction(c, info) if tc.wantErr != "" { require.NotNil(t, taskErr) assert.Equal(t, "plugin_request_invalid", taskErr.Code) assert.Contains(t, taskErr.Message, tc.wantErr) return } require.Nil(t, taskErr) reader, err := adaptor.BuildRequestBody(c, info) require.NoError(t, err) encoded, err := io.ReadAll(reader) require.NoError(t, err) var body map[string]any require.NoError(t, common.Unmarshal(encoded, &body)) assert.Equal(t, endpoint, body["model"]) facts, err := adaptor.ExtractUsageFactsValidated(c, info) require.NoError(t, err) assert.Equal(t, map[string]any{"images_up_to_1_5k": float64(0), "images_above_1_5k": float64(1), "input_images": float64(0), "layer_decomposition": false}, alibabaObject(t, facts)) }) } }