1015 lines
58 KiB
Go
1015 lines
58 KiB
Go
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))
|
||
})
|
||
}
|
||
}
|