feat: publish sanitized custom.97 source

This commit is contained in:
cneicy committed 2026-10-27 20:43:39 +08:00
1 parent 8e6ed0317b
commit 86c0276156
1022 files changed
+69903 -19207

No files matched your search

+984 -1
View File
@@ -1,6 +1,32 @@
package plugins_test
import "testing"
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{
@@ -29,3 +55,960 @@ func TestDoubaoResponsesProtocol(t *testing.T) {
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))
})
}
}