feat: sync deployed v1.0.0-rc.38-custom.42
Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 1m40s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 42m16s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s
Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 1m40s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 42m16s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s
Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
This commit is contained in:
1 parent
c2b44332d6
commit
a5f7b8e586
828 files changed
+122253
-13702
No files matched your search
@@ -1,12 +1,26 @@
|
||||
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/model"
|
||||
"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"
|
||||
relaychannel "github.com/QuantumNous/new-api/relay/channel"
|
||||
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"
|
||||
)
|
||||
@@ -27,9 +41,32 @@ func TestAlibabaResponsesProtocol(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("selects image and video usage profiles for every declared model", func(t *testing.T) {
|
||||
require.Len(t, plugin.Meta.UsageProfiles, 2)
|
||||
covered := make(map[string]bool)
|
||||
for _, profile := range plugin.Meta.UsageProfiles {
|
||||
for _, name := range profile.Models {
|
||||
require.False(t, covered[name], name)
|
||||
covered[name] = true
|
||||
}
|
||||
}
|
||||
for _, name := range plugin.Meta.Models {
|
||||
require.True(t, covered[name], name)
|
||||
schema, examples := plugin.Meta.UsageForModel(name)
|
||||
assert.Empty(t, examples)
|
||||
isImage := strings.Contains(name, "-image") || strings.Contains(name, "-t2i")
|
||||
if isImage {
|
||||
assert.Equal(t, map[string]jsplugin.UsageFieldSchema{"image_count": plugin.Meta.UsageSchema["image_count"]}, schema, name)
|
||||
} else {
|
||||
assert.Equal(t, map[string]jsplugin.UsageFieldSchema{
|
||||
"seconds": plugin.Meta.UsageSchema["seconds"], "resolution": plugin.Meta.UsageSchema["resolution"],
|
||||
}, schema, name)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("declares documented usage facts", func(t *testing.T) {
|
||||
require.Len(t, plugin.Meta.UsageSchema, 2)
|
||||
for _, key := range []string{"seconds", "resolution"} {
|
||||
for _, key := range []string{"seconds", "resolution", "image_count"} {
|
||||
schema, exists := plugin.Meta.UsageSchema[key]
|
||||
require.True(t, exists, key)
|
||||
assert.NotEmpty(t, schema.Description, key)
|
||||
@@ -250,3 +287,691 @@ func TestAlibabaResponsesProtocol(t *testing.T) {
|
||||
assert.NotContains(t, callErr.Error(), "upstream.example")
|
||||
})
|
||||
}
|
||||
|
||||
func newAlibabaPlugin(t *testing.T) *jsplugin.LoadedPlugin {
|
||||
t.Helper()
|
||||
source, err := builtinplugins.Source("alibaba")
|
||||
require.NoError(t, err)
|
||||
plugin, err := jsplugin.NewRegistry().RegisterFactory(source, jsplugin.Options{Key: "alibaba"})
|
||||
require.NoError(t, err)
|
||||
return plugin
|
||||
}
|
||||
|
||||
func alibabaObject(t *testing.T, value any) map[string]any {
|
||||
t.Helper()
|
||||
encoded, err := common.Marshal(value)
|
||||
require.NoError(t, err)
|
||||
var result map[string]any
|
||||
require.NoError(t, common.Unmarshal(encoded, &result))
|
||||
return result
|
||||
}
|
||||
|
||||
// Exercise the production host's recursive usage validation as well as the
|
||||
// converter, without sending a paid generation request or needing a database.
|
||||
func submitAlibabaRequest(t *testing.T, plugin *jsplugin.LoadedPlugin, upstream string, request map[string]any) (map[string]any, map[string]any, string) {
|
||||
t.Helper()
|
||||
info := &relaycommon.RelayInfo{
|
||||
ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://dashscope.aliyuncs.com", UpstreamModelName: upstream},
|
||||
OriginModelName: request["model"].(string),
|
||||
TaskRelayInfo: &relaycommon.TaskRelayInfo{PublicTaskID: "task_public"},
|
||||
}
|
||||
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)
|
||||
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 TestAlibabaWanModelCapabilities(t *testing.T) {
|
||||
plugin := newAlibabaPlugin(t)
|
||||
for _, tc := range []struct {
|
||||
model string
|
||||
input map[string]any
|
||||
resolution string
|
||||
allowed []string
|
||||
size string
|
||||
ratio string
|
||||
service string
|
||||
seconds float64
|
||||
}{
|
||||
{"wan3.0-video", map[string]any{"prompt": "a cat"}, "1080P", []string{"480P", "720P", "1080P"}, "", "adaptive", "video-generation", 5},
|
||||
{"wan3.0-video-prime", map[string]any{"prompt": "a cat"}, "1080P", []string{"480P", "720P", "1080P"}, "", "adaptive", "video-generation", 5},
|
||||
{"wan2.7-t2v", map[string]any{"prompt": "a cat"}, "1080P", []string{"720P", "1080P"}, "", "16:9", "video-generation", 5},
|
||||
{"wan2.7-t2v-2026-04-25", map[string]any{"prompt": "a cat"}, "1080P", []string{"720P", "1080P"}, "", "16:9", "video-generation", 5},
|
||||
{"wan2.7-t2v-2026-06-12", map[string]any{"prompt": "a cat"}, "1080P", []string{"720P", "1080P"}, "", "16:9", "video-generation", 5},
|
||||
{"wan2.7-i2v", map[string]any{"media": []any{map[string]any{"type": "first_frame", "url": "https://cdn.example/first.png"}}}, "1080P", []string{"720P", "1080P"}, "", "", "video-generation", 5},
|
||||
{"wan2.7-i2v-2026-04-25", map[string]any{"media": []any{map[string]any{"type": "first_clip", "url": "https://cdn.example/first.mp4"}}}, "1080P", []string{"720P", "1080P"}, "", "", "video-generation", 5},
|
||||
{"wan2.6-t2v", map[string]any{"prompt": "a cat"}, "1080P", []string{"720P", "1080P"}, "1920*1080", "", "video-generation", 5},
|
||||
{"wan2.6-t2v-us", map[string]any{"prompt": "a cat"}, "1080P", []string{"720P", "1080P"}, "1920*1080", "", "video-generation", 5},
|
||||
{"wan2.6-i2v", map[string]any{"img_url": "https://cdn.example/first.png"}, "1080P", []string{"720P", "1080P"}, "", "", "video-generation", 5},
|
||||
{"wan2.6-i2v-flash", map[string]any{"img_url": "https://cdn.example/first.png"}, "1080P", []string{"720P", "1080P"}, "", "", "video-generation", 5},
|
||||
{"wan2.6-i2v-us", map[string]any{"img_url": "https://cdn.example/first.png"}, "1080P", []string{"720P", "1080P"}, "", "", "video-generation", 5},
|
||||
{"wan2.5-t2v-preview", map[string]any{"prompt": "a cat"}, "1080P", []string{"480P", "720P", "1080P"}, "1920*1080", "", "video-generation", 5},
|
||||
{"wan2.5-i2v-preview", map[string]any{"img_url": "https://cdn.example/first.png"}, "1080P", []string{"480P", "720P", "1080P"}, "", "", "video-generation", 5},
|
||||
{"wan2.2-t2v-plus", map[string]any{"prompt": "a cat"}, "1080P", []string{"480P", "1080P"}, "1920*1080", "", "video-generation", 5},
|
||||
{"wan2.2-i2v-plus", map[string]any{"img_url": "https://cdn.example/first.png"}, "1080P", []string{"480P", "1080P"}, "", "", "video-generation", 5},
|
||||
{"wan2.2-i2v-flash", map[string]any{"img_url": "https://cdn.example/first.png"}, "720P", []string{"480P", "720P", "1080P"}, "", "", "video-generation", 5},
|
||||
{"wan2.2-kf2v-flash", map[string]any{"first_frame_url": "https://cdn.example/first.png", "last_frame_url": "https://cdn.example/last.png"}, "720P", []string{"480P", "720P", "1080P"}, "", "", "image2video", 5},
|
||||
{"wan2.2-s2v", map[string]any{"image_url": "https://cdn.example/first.png", "audio_url": "https://cdn.example/audio.mp3"}, "480P", []string{"480P", "720P"}, "", "", "image2video", 20},
|
||||
{"wanx2.1-t2v-plus", map[string]any{"prompt": "a cat"}, "720P", []string{"720P"}, "1280*720", "", "video-generation", 5},
|
||||
{"wanx2.1-t2v-turbo", map[string]any{"prompt": "a cat"}, "720P", []string{"480P", "720P"}, "1280*720", "", "video-generation", 5},
|
||||
{"wanx2.1-i2v-plus", map[string]any{"img_url": "https://cdn.example/first.png"}, "720P", []string{"720P"}, "", "", "video-generation", 5},
|
||||
{"wanx2.1-i2v-turbo", map[string]any{"img_url": "https://cdn.example/first.png"}, "720P", []string{"480P", "720P"}, "", "", "video-generation", 5},
|
||||
} {
|
||||
t.Run(tc.model, func(t *testing.T) {
|
||||
value, err := plugin.Engine.CallPath(t.Context(), "native", []string{"createVideoTask"}, map[string]any{
|
||||
"body": map[string]any{"kind": "json", "value": map[string]any{"model": tc.model, "input": tc.input}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
request := alibabaObject(t, value)["requestBody"].(map[string]any)
|
||||
body, facts, url := submitAlibabaRequest(t, plugin, tc.model, request)
|
||||
assert.Equal(t, "https://dashscope.aliyuncs.com/api/v1/services/aigc/"+tc.service+"/video-synthesis", url)
|
||||
assert.Equal(t, map[string]any{"seconds": tc.seconds, "resolution": tc.resolution}, facts)
|
||||
assert.Equal(t, tc.input, body["input"])
|
||||
parameters := body["parameters"].(map[string]any)
|
||||
if tc.size != "" {
|
||||
assert.Equal(t, tc.size, parameters["size"])
|
||||
assert.NotContains(t, parameters, "resolution")
|
||||
} else {
|
||||
assert.Equal(t, tc.resolution, parameters["resolution"])
|
||||
assert.NotContains(t, parameters, "size")
|
||||
}
|
||||
if tc.ratio != "" {
|
||||
assert.Equal(t, tc.ratio, parameters["ratio"])
|
||||
} else {
|
||||
assert.NotContains(t, parameters, "ratio")
|
||||
}
|
||||
if tc.model == "wan2.2-s2v" {
|
||||
assert.NotContains(t, parameters, "duration")
|
||||
} else {
|
||||
assert.Equal(t, float64(5), parameters["duration"])
|
||||
}
|
||||
for _, resolution := range tc.allowed {
|
||||
request["resolution"] = resolution
|
||||
_, facts, _ := submitAlibabaRequest(t, plugin, tc.model, request)
|
||||
assert.Equal(t, resolution, facts["resolution"])
|
||||
}
|
||||
for _, resolution := range []string{"480P", "720P", "1080P", "4K"} {
|
||||
if slices.Contains(tc.allowed, resolution) {
|
||||
continue
|
||||
}
|
||||
request["metadata"].(map[string]any)["parameters"] = map[string]any{"resolution": resolution}
|
||||
_, err = plugin.Engine.Call(t.Context(), "buildSubmitRequest", map[string]any{"requestBody": request, "upstreamModel": tc.model})
|
||||
require.ErrorContains(t, err, "resolution must be", resolution)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlibabaWanCompatibilityParameters(t *testing.T) {
|
||||
plugin := newAlibabaPlugin(t)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
model string
|
||||
request map[string]any
|
||||
parameters map[string]any
|
||||
}{
|
||||
{"legacy dimensions retain vertical ratio", "wan2.7-t2v", map[string]any{"size": "720*1280"}, map[string]any{"resolution": "720P", "ratio": "9:16"}},
|
||||
{"OpenAI dimension separator", "wan3.0-video", map[string]any{"size": "1080x1920"}, map[string]any{"resolution": "1080P", "ratio": "9:16"}},
|
||||
{"legacy four thirds", "wan2.7-t2v", map[string]any{"size": "1088*832"}, map[string]any{"resolution": "720P", "ratio": "4:3"}},
|
||||
{"modern four thirds", "wan2.7-t2v", map[string]any{"size": "1648*1248"}, map[string]any{"resolution": "1080P", "ratio": "4:3"}},
|
||||
{"modern dimensions to legacy protocol", "wan2.5-t2v-preview", map[string]any{"size": "832*1104"}, map[string]any{"size": "832*1088"}},
|
||||
{"resolution tier compatibility", "wan2.6-t2v", map[string]any{"size": "720p", "ratio": "1:1"}, map[string]any{"size": "960*960"}},
|
||||
{"unified fields override compatibility size", "wan2.7-t2v", map[string]any{"size": "720*1280", "resolution": "1080P", "ratio": "1:1"}, map[string]any{"resolution": "1080P", "ratio": "1:1"}},
|
||||
{"native fields override unified fields", "wan2.7-t2v", map[string]any{"size": "720*1280", "resolution": "720P", "ratio": "1:1", "metadata": map[string]any{"parameters": map[string]any{"resolution": "1080P", "ratio": "3:4"}}}, map[string]any{"resolution": "1080P", "ratio": "3:4"}},
|
||||
{"native legacy size overrides unified fields", "wan2.6-t2v", map[string]any{"resolution": "1080P", "ratio": "1:1", "metadata": map[string]any{"parameters": map[string]any{"size": "720*1280"}}}, map[string]any{"size": "720*1280"}},
|
||||
{"image size only selects tier", "wan2.7-i2v", map[string]any{"image": "https://cdn.example/first.png", "size": "720*1280", "ratio": "1:1"}, map[string]any{"resolution": "720P"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
request := maps.Clone(tc.request)
|
||||
request["model"], request["prompt"] = "my-wan-alias", "a cat"
|
||||
body, _, _ := submitAlibabaRequest(t, plugin, tc.model, request)
|
||||
assert.Equal(t, tc.model, body["model"])
|
||||
parameters := body["parameters"].(map[string]any)
|
||||
delete(parameters, "duration")
|
||||
delete(parameters, "prompt_extend")
|
||||
assert.Equal(t, tc.parameters, parameters)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlibabaWanEntrypointsPreserveOptions(t *testing.T) {
|
||||
plugin := newAlibabaPlugin(t)
|
||||
input := map[string]any{"prompt": "a cat", "negative_prompt": "blur", "audio_url": "https://cdn.example/audio.mp3"}
|
||||
parameters := map[string]any{"resolution": "720P", "ratio": "9:16", "duration": float64(6), "prompt_extend": false, "watermark": false, "seed": float64(0)}
|
||||
for _, entry := range []string{"native", "openai_responses", "openai_video", "multipart"} {
|
||||
t.Run(entry, func(t *testing.T) {
|
||||
ctx := map[string]any{"model": "wan2.7-t2v"}
|
||||
var value any
|
||||
var err error
|
||||
if entry == "native" {
|
||||
ctx["body"] = map[string]any{"kind": "json", "value": map[string]any{"model": "wan2.7-t2v", "input": input, "parameters": parameters}}
|
||||
value, err = plugin.Engine.CallPath(t.Context(), "native", []string{"createVideoTask"}, ctx)
|
||||
} else {
|
||||
request := maps.Clone(input)
|
||||
maps.Copy(request, parameters)
|
||||
request["model"] = "wan2.7-t2v"
|
||||
request["resolution"] = "720p"
|
||||
if entry == "openai_responses" {
|
||||
request["input"] = request["prompt"]
|
||||
}
|
||||
protocol := entry
|
||||
ctx["body"] = map[string]any{"kind": "json", "value": request}
|
||||
if entry == "multipart" {
|
||||
protocol = "openai_video"
|
||||
ctx["body"] = map[string]any{"kind": "multipart", "fields": map[string]any{
|
||||
"prompt": []string{"a cat"}, "negative_prompt": []string{"blur"}, "audio_url": []string{"https://cdn.example/audio.mp3"},
|
||||
"resolution": []string{"720p"}, "ratio": []string{"9:16"}, "duration": []string{"6"},
|
||||
"prompt_extend": []string{"false"}, "watermark": []string{"false"}, "seed": []string{"0"},
|
||||
}}
|
||||
}
|
||||
value, err = plugin.Engine.CallPath(t.Context(), "protocols", []string{protocol, "decodeRequest"}, ctx)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
request := alibabaObject(t, value)["requestBody"].(map[string]any)
|
||||
body, facts, _ := submitAlibabaRequest(t, plugin, "wan2.7-t2v", request)
|
||||
assert.Equal(t, input, body["input"])
|
||||
assert.Equal(t, parameters, body["parameters"])
|
||||
assert.Equal(t, map[string]any{"seconds": float64(6), "resolution": "720P"}, facts)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlibabaWanDurationValidation(t *testing.T) {
|
||||
plugin := newAlibabaPlugin(t)
|
||||
for _, tc := range []struct {
|
||||
model string
|
||||
valid []int
|
||||
invalid []any
|
||||
}{
|
||||
{"wan3.0-video", []int{-1, 2, 30}, []any{0, 1, 31, 2.5}},
|
||||
{"wan2.7-t2v", []int{2, 15}, []any{-1, 0, 1, 16, 2.5}},
|
||||
{"wan2.6-i2v", []int{2, 15}, []any{1, 16}},
|
||||
{"wan2.6-t2v-us", []int{5, 10, 15}, []any{2, 6, 16}},
|
||||
{"wan2.5-t2v-preview", []int{5, 10}, []any{6, 15}},
|
||||
{"wan2.2-i2v-flash", []int{5}, []any{3, 10}},
|
||||
{"wan2.2-kf2v-flash", []int{5}, []any{3, 10}},
|
||||
{"wanx2.1-i2v-turbo", []int{3, 4, 5}, []any{2, 6}},
|
||||
{"wan2.2-s2v", []int{1, 19}, []any{-1, 0, 20, 3601}},
|
||||
} {
|
||||
t.Run(tc.model, func(t *testing.T) {
|
||||
request := map[string]any{"model": tc.model, "prompt": "a cat", "image": "https://cdn.example/first.png"}
|
||||
if tc.model == "wan2.2-s2v" {
|
||||
request["audio_url"] = "https://cdn.example/audio.mp3"
|
||||
}
|
||||
ctx := map[string]any{"requestBody": request, "upstreamModel": tc.model}
|
||||
for _, duration := range tc.valid {
|
||||
request["duration"] = duration
|
||||
_, err := plugin.Engine.Call(t.Context(), "buildSubmitRequest", ctx)
|
||||
require.NoError(t, err, "duration=%v", duration)
|
||||
}
|
||||
request["duration"] = 5
|
||||
for _, duration := range append(tc.invalid, "bad", true, 1e30) {
|
||||
request["metadata"] = map[string]any{"parameters": map[string]any{"duration": duration}}
|
||||
_, err := plugin.Engine.Call(t.Context(), "buildSubmitRequest", ctx)
|
||||
require.ErrorContains(t, err, "duration", "duration=%v", duration)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Shadowed -1 values must not override explicit metadata duration, nor
|
||||
// mutate a request that will be decoded again after channel selection.
|
||||
request := map[string]any{"model": "wan3.0-video", "prompt": "a cat", "duration": -1, "metadata": map[string]any{"parameters": map[string]any{"duration": 8}}}
|
||||
value, err := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_video", "decodeRequest"}, map[string]any{"model": "wan3.0-video", "body": map[string]any{"kind": "json", "value": request}})
|
||||
require.NoError(t, err)
|
||||
decoded := alibabaObject(t, value)["requestBody"].(map[string]any)
|
||||
assert.NotContains(t, decoded, "auto_duration")
|
||||
body, facts, _ := submitAlibabaRequest(t, plugin, "wan3.0-video", decoded)
|
||||
assert.Equal(t, float64(8), body["parameters"].(map[string]any)["duration"])
|
||||
assert.Equal(t, float64(8), facts["seconds"])
|
||||
|
||||
request["duration"] = 8
|
||||
request["metadata"] = map[string]any{"parameters": map[string]any{"duration": -1}}
|
||||
value, err = plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_video", "decodeRequest"}, map[string]any{"model": "wan3.0-video", "body": map[string]any{"kind": "json", "value": request}})
|
||||
require.NoError(t, err)
|
||||
decoded = alibabaObject(t, value)["requestBody"].(map[string]any)
|
||||
body, facts, _ = submitAlibabaRequest(t, plugin, "wan3.0-video", decoded)
|
||||
assert.Equal(t, float64(-1), body["parameters"].(map[string]any)["duration"])
|
||||
assert.Equal(t, float64(30), facts["seconds"])
|
||||
}
|
||||
|
||||
func TestAlibabaWanMediaInputs(t *testing.T) {
|
||||
plugin := newAlibabaPlugin(t)
|
||||
first := "https://cdn.example/first.png"
|
||||
last := "https://cdn.example/last.png"
|
||||
audio := "https://cdn.example/audio.mp3"
|
||||
clip := "https://cdn.example/clip.mp4"
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
model string
|
||||
request map[string]any
|
||||
input map[string]any
|
||||
seconds float64
|
||||
}{
|
||||
{"2.7 frame and driving audio", "wan2.7-i2v", map[string]any{"image": first, "audio_url": audio}, map[string]any{"media": []any{map[string]any{"type": "first_frame", "url": first}, map[string]any{"type": "driving_audio", "url": audio}}}, 5},
|
||||
{"2.7 frame pair", "wan2.7-i2v", map[string]any{"images": []string{first, last}}, map[string]any{"media": []any{map[string]any{"type": "first_frame", "url": first}, map[string]any{"type": "last_frame", "url": last}}}, 5},
|
||||
{"legacy frame pair", "wan2.2-kf2v-flash", map[string]any{"images": []string{first, last}}, map[string]any{"first_frame_url": first, "last_frame_url": last}, 5},
|
||||
{"digital human", "wan2.2-s2v", map[string]any{"image": first, "audio_url": audio}, map[string]any{"image_url": first, "audio_url": audio}, 20},
|
||||
{"2.7 continuation counts total duration", "wan2.7-i2v", map[string]any{"duration": 15, "metadata": map[string]any{"input": map[string]any{"media": []any{map[string]any{"type": "first_clip", "url": clip}, map[string]any{"type": "last_frame", "url": last}}}}}, map[string]any{"media": []any{map[string]any{"type": "first_clip", "url": clip}, map[string]any{"type": "last_frame", "url": last}}}, 15},
|
||||
{"Wan3 reference video reserves input and output", "wan3.0-video", map[string]any{"duration": 5, "media": []any{map[string]any{"type": "reference_video", "url": clip}, map[string]any{"type": "reference_audio", "url": audio}}}, map[string]any{"media": []any{map[string]any{"type": "reference_video", "url": clip}, map[string]any{"type": "reference_audio", "url": audio}}}, 30},
|
||||
{"Wan3 frame pair", "wan3.0-video-prime", map[string]any{"images": []string{first, last}, "audio": false}, map[string]any{"media": []any{map[string]any{"type": "first_frame", "url": first}, map[string]any{"type": "last_frame", "url": last}}}, 5},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
request := maps.Clone(tc.request)
|
||||
request["model"] = tc.model
|
||||
body, facts, _ := submitAlibabaRequest(t, plugin, tc.model, request)
|
||||
assert.Equal(t, tc.input, body["input"])
|
||||
assert.Equal(t, tc.seconds, facts["seconds"])
|
||||
if request["audio"] == false {
|
||||
assert.Equal(t, false, body["parameters"].(map[string]any)["audio"])
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
model string
|
||||
media []any
|
||||
}{
|
||||
{"missing visual input", "wan2.7-i2v", []any{map[string]any{"type": "driving_audio", "url": audio}}},
|
||||
{"duplicate first frame", "wan2.7-i2v", []any{map[string]any{"type": "first_frame", "url": first}, map[string]any{"type": "first_frame", "url": last}}},
|
||||
{"continuation with driving audio", "wan2.7-i2v", []any{map[string]any{"type": "first_clip", "url": clip}, map[string]any{"type": "driving_audio", "url": audio}}},
|
||||
{"mixed frames and references", "wan3.0-video", []any{map[string]any{"type": "first_frame", "url": first}, map[string]any{"type": "reference_video", "url": clip}}},
|
||||
{"unknown media type", "wan3.0-video", []any{map[string]any{"type": "first_clip", "url": clip}}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := plugin.Engine.Call(t.Context(), "buildSubmitRequest", map[string]any{"requestBody": map[string]any{
|
||||
"model": tc.model, "metadata": map[string]any{"input": map[string]any{"media": tc.media}},
|
||||
}})
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlibabaWanCompletionFactsAndArtifacts(t *testing.T) {
|
||||
plugin := newAlibabaPlugin(t)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
model string
|
||||
usage map[string]any
|
||||
facts map[string]any
|
||||
}{
|
||||
{"digital human fractional duration", "wan2.2-s2v", map[string]any{"duration": 18.13, "SR": 480}, map[string]any{"seconds": 18.13, "resolution": "480P"}},
|
||||
{"Wan3 input plus output", "wan3.0-video", map[string]any{"input_video_duration": 4.25, "output_video_duration": 6.5, "duration": 6.5, "SR": 720}, map[string]any{"seconds": 10.75, "resolution": "720P"}},
|
||||
{"Wan3 without reference video", "wan3.0-video-prime", map[string]any{"input_video_duration": 0, "output_video_duration": 8, "SR": 1080}, map[string]any{"seconds": float64(8), "resolution": "1080P"}},
|
||||
{"Wan3 missing input duration preserves reserved seconds", "wan3.0-video", map[string]any{"output_video_duration": 6, "SR": 720}, map[string]any{"resolution": "720P"}},
|
||||
{"2.7 continuation uses total duration", "wan2.7-i2v", map[string]any{"duration": 15, "output_video_duration": 12, "SR": 1080}, map[string]any{"seconds": float64(15), "resolution": "1080P"}},
|
||||
{"missing usage keeps reservation", "wan2.7-t2v", map[string]any{}, nil},
|
||||
{"oversized duration rejected by host", "wan2.2-s2v", map[string]any{"duration": 1e30, "SR": 480}, nil},
|
||||
{"negative input cannot reduce Wan3 charge", "wan3.0-video", map[string]any{"input_video_duration": -5, "output_video_duration": 10}, nil},
|
||||
{"negative output cannot reduce Wan3 charge", "wan3.0-video", map[string]any{"input_video_duration": 10, "output_video_duration": -5}, nil},
|
||||
{"non-numeric duration rejected by host", "wan2.2-s2v", map[string]any{"duration": "invalid", "SR": 480}, nil},
|
||||
{"unknown resolution rejected by host", "wan2.7-t2v", map[string]any{"duration": 5, "SR": 4320}, nil},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
adaptor := taskplugin.New(plugin)
|
||||
task := &model.Task{Properties: model.Properties{OriginModelName: "my-alias", UpstreamModelName: tc.model}}
|
||||
encoded, err := common.Marshal(map[string]any{
|
||||
"output": map[string]any{"task_status": "SUCCEEDED", "results": map[string]any{"video_url": "https://cdn.example/result.mp4"}},
|
||||
"usage": tc.usage,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result, err := adaptor.ParseTaskResult(task, &http.Response{StatusCode: http.StatusOK, Header: make(http.Header)}, encoded)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "SUCCESS", result.Status)
|
||||
assert.Equal(t, "https://cdn.example/result.mp4", result.Url)
|
||||
if tc.facts == nil {
|
||||
assert.Nil(t, result.UsageFacts)
|
||||
} else {
|
||||
assert.Equal(t, tc.facts, alibabaObject(t, result.UsageFacts))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
adaptor := taskplugin.New(plugin)
|
||||
task := &model.Task{TaskID: "task_public", Status: model.TaskStatusSuccess, Properties: model.Properties{OriginModelName: "wan2.2-s2v"}}
|
||||
task.SetData(map[string]any{"output": map[string]any{"task_status": "SUCCEEDED", "results": map[string]any{"video_url": "https://cdn.example/result.mp4"}}})
|
||||
artifacts, err := adaptor.ListArtifacts(task)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, artifacts, 1)
|
||||
assert.Equal(t, "video", artifacts[0].Key)
|
||||
value, err := plugin.Engine.Call(t.Context(), "buildContentRequest", map[string]any{"artifactKey": "video", "data": map[string]any{"output": map[string]any{"results": map[string]any{"video_url": "https://cdn.example/result.mp4"}}}, "clientRequest": map[string]any{"method": "GET"}})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://cdn.example/result.mp4", alibabaObject(t, value)["url"])
|
||||
}
|
||||
|
||||
func TestAlibabaImageSubmission(t *testing.T) {
|
||||
source, err := builtinplugins.Source("alibaba")
|
||||
require.NoError(t, err)
|
||||
registry := jsplugin.NewRegistry()
|
||||
plugin, err := registry.RegisterFactory(source, jsplugin.Options{Key: "alibaba"})
|
||||
require.NoError(t, err)
|
||||
const base = "/api/v1/services/aigc/"
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
model string
|
||||
input map[string]any
|
||||
parameters map[string]any
|
||||
path string
|
||||
count float64
|
||||
}{
|
||||
{"legacy text", "wan2.2-t2i-flash", map[string]any{"prompt": "a cat"}, map[string]any{"n": 2}, "text2image/image-synthesis", 2},
|
||||
{"legacy default count", "wanx2.1-t2i-plus", map[string]any{"prompt": "a cat"}, nil, "text2image/image-synthesis", 4},
|
||||
{"2.6 text", "wan2.6-t2i", map[string]any{"messages": []any{map[string]any{"role": "user", "content": []any{map[string]any{"text": "a cat"}}}}}, map[string]any{"n": 2, "seed": 0, "watermark": false, "size": "960*1696"}, "image-generation/generation", 2},
|
||||
{"2.6 synchronous", "wan2.6-t2i", map[string]any{"messages": []any{map[string]any{"role": "user", "content": []any{map[string]any{"text": "a cat"}}}}}, nil, "multimodal-generation/generation", 1},
|
||||
{"2.7 group default count", "wan2.7-image", map[string]any{"messages": []any{map[string]any{"role": "user", "content": []any{map[string]any{"text": "seasons"}}}}}, map[string]any{"enable_sequential": true}, "image-generation/generation", 12},
|
||||
{"2.6 interleaved default count", "wan2.6-image", map[string]any{"messages": []any{map[string]any{"role": "user", "content": []any{map[string]any{"text": "recipe"}}}}}, map[string]any{"enable_interleave": true}, "image-generation/generation", 5},
|
||||
{"2.6 edit", "wan2.6-image", map[string]any{"messages": []any{map[string]any{"role": "user", "content": []any{map[string]any{"image": "https://cdn.example/input.png"}, map[string]any{"text": "watercolor"}}}}}, map[string]any{"size": "2K", "n": 1}, "multimodal-generation/generation", 1},
|
||||
{"2.7 4K", "wan2.7-image-pro", map[string]any{"messages": []any{map[string]any{"role": "user", "content": []any{map[string]any{"text": "a cat"}}}}}, map[string]any{"size": "4K"}, "multimodal-generation/generation", 1},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
path := "/ali" + base + tc.path
|
||||
binding, found := registry.Generation().LookupDeclaredRoute(http.MethodPost, path)
|
||||
require.True(t, found)
|
||||
assert.Contains(t, binding.Route.Models, tc.model)
|
||||
value, err := plugin.Engine.CallPath(t.Context(), "native", []string{binding.Route.Decode}, map[string]any{
|
||||
"path": path, "body": map[string]any{"kind": "json", "value": map[string]any{"model": tc.model, "input": tc.input, "parameters": tc.parameters}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
request := alibabaObject(t, value)["requestBody"].(map[string]any)
|
||||
body, facts, url := submitAlibabaRequest(t, plugin, tc.model, request)
|
||||
assert.Equal(t, "https://dashscope.aliyuncs.com"+base+tc.path, url)
|
||||
assert.Equal(t, alibabaObject(t, tc.input), body["input"])
|
||||
assert.Equal(t, map[string]any{"image_count": tc.count}, facts)
|
||||
parameters := body["parameters"].(map[string]any)
|
||||
for key, expected := range alibabaObject(t, tc.parameters) {
|
||||
assert.Equal(t, expected, parameters[key], key)
|
||||
}
|
||||
value, err = plugin.Engine.Call(t.Context(), "buildSubmitRequest", map[string]any{"upstreamModel": tc.model, "requestBody": request})
|
||||
require.NoError(t, err)
|
||||
headers := alibabaObject(t, value)["headers"].(map[string]any)
|
||||
if tc.path == "multimodal-generation/generation" {
|
||||
assert.NotContains(t, headers, "X-DashScope-Async")
|
||||
} else {
|
||||
assert.Equal(t, "enable", headers["X-DashScope-Async"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("Responses images use task artifacts and do not claim video creation", func(t *testing.T) {
|
||||
for _, name := range []string{"wan2.6-t2i", "wan2.7-image", "wanx2.1-t2i-plus"} {
|
||||
_, found := registry.Generation().LookupEndpoint(http.MethodPost, "/v1/responses", name)
|
||||
require.True(t, found)
|
||||
_, found = registry.Generation().LookupEndpoint(http.MethodPost, "/v1/videos", name)
|
||||
assert.False(t, found)
|
||||
}
|
||||
value, err := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "decodeRequest"}, map[string]any{
|
||||
"model": "my-image-alias", "upstreamModel": "wan2.7-image", "body": map[string]any{"kind": "json", "value": map[string]any{
|
||||
"input": []any{map[string]any{"type": "input_text", "text": "watercolor"}, map[string]any{"type": "input_image", "image_url": "https://cdn.example/input.png"}}, "n": 2,
|
||||
}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
resolved := alibabaObject(t, value)
|
||||
assert.Equal(t, "image_to_image", resolved["action"])
|
||||
body, facts, url := submitAlibabaRequest(t, plugin, "wan2.7-image", resolved["requestBody"].(map[string]any))
|
||||
assert.Equal(t, "wan2.7-image", body["model"])
|
||||
assert.Equal(t, map[string]any{"image_count": float64(2)}, facts)
|
||||
assert.Equal(t, "https://dashscope.aliyuncs.com"+base+"image-generation/generation", url)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAlibabaImageValidation(t *testing.T) {
|
||||
plugin := newAlibabaPlugin(t)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
model string
|
||||
request map[string]any
|
||||
message string
|
||||
}{
|
||||
{"zero count", "wan2.6-t2i", map[string]any{"n": 0}, "n must be"},
|
||||
{"fractional count", "wan2.6-t2i", map[string]any{"n": 1.5}, "n must be"},
|
||||
{"string count", "wan2.6-t2i", map[string]any{"n": "2"}, "n must be"},
|
||||
{"oversized nested count", "wan2.6-t2i", map[string]any{"metadata": map[string]any{"parameters": map[string]any{"n": 1e30}}}, "n must be"},
|
||||
{"negative nested count", "wan2.6-t2i", map[string]any{"metadata": map[string]any{"parameters": map[string]any{"n": -1}}}, "n must be"},
|
||||
{"group limit", "wan2.7-image", map[string]any{"enable_sequential": true, "n": 13}, "n must be"},
|
||||
{"interleaved limit", "wan2.6-image", map[string]any{"enable_interleave": true, "max_images": 6}, "max_images must be"},
|
||||
{"nested interleaved limit", "wan2.6-image", map[string]any{"metadata": map[string]any{"parameters": map[string]any{"enable_interleave": true, "max_images": 1e30}}}, "max_images must be"},
|
||||
{"interleaved n", "wan2.6-image", map[string]any{"enable_interleave": true, "n": 2}, "n must be"},
|
||||
{"string mode flag", "wan2.7-image", map[string]any{"enable_sequential": "true"}, "must be a boolean"},
|
||||
{"upstream SSE", "wan2.6-image", map[string]any{"metadata": map[string]any{"parameters": map[string]any{"stream": true}}}, "upstream streaming requires"},
|
||||
{"legacy synchronous", "wan2.2-t2i-flash", map[string]any{"metadata": map[string]any{"upstream_mode": "sync"}}, "only supports asynchronous"},
|
||||
{"edit without reference", "wan2.6-image", nil, "requires a reference image"},
|
||||
{"t2i with reference", "wan2.6-t2i", map[string]any{"image": "https://cdn.example/input.png"}, "too many input images"},
|
||||
{"invalid 4K", "wan2.7-image", map[string]any{"size": "4K"}, "4K is only supported"},
|
||||
{"oversized pixel dimensions", "wan2.7-image", map[string]any{"size": "999999999999*999999999999"}, "pixel and aspect-ratio limits"},
|
||||
{"4K group dimensions", "wan2.7-image-pro", map[string]any{"size": "4096*4096", "enable_sequential": true}, "pixel and aspect-ratio limits"},
|
||||
{"model override", "wan2.7-image", map[string]any{"metadata": map[string]any{"model": "wan2.7-image-pro"}}, "can't change model"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
request := map[string]any{"model": tc.model, "prompt": "a cat"}
|
||||
maps.Copy(request, tc.request)
|
||||
_, err := plugin.Engine.Call(t.Context(), "buildSubmitRequest", map[string]any{"requestBody": request, "upstreamModel": tc.model})
|
||||
require.ErrorContains(t, err, tc.message)
|
||||
// The production validator must reject the same input before HTTP/billing.
|
||||
info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: tc.model}, TaskRelayInfo: &relaycommon.TaskRelayInfo{}}
|
||||
adaptor := taskplugin.New(plugin)
|
||||
adaptor.Init(info)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
c.Set("task_request", request)
|
||||
taskErr := adaptor.ValidateRequestAndSetAction(c, info)
|
||||
require.NotNil(t, taskErr)
|
||||
assert.Equal(t, http.StatusBadRequest, taskErr.StatusCode)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlibabaImageResults(t *testing.T) {
|
||||
plugin := newAlibabaPlugin(t)
|
||||
const first = "https://cdn.example/first.png"
|
||||
const second = "https://cdn.example/second.png"
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
model string
|
||||
output map[string]any
|
||||
}{
|
||||
{"legacy partial success", "wan2.2-t2i-flash", map[string]any{"results": []any{map[string]any{"url": first}, map[string]any{"code": "DataInspectionFailed"}, map[string]any{"url": second}}}},
|
||||
{"modern interleaved output", "wan2.6-image", map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": []any{map[string]any{"text": "Step 1"}, map[string]any{"image": first}, map[string]any{"text": "Step 2"}, map[string]any{"image": second}}}}}}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
output := maps.Clone(tc.output)
|
||||
output["task_status"] = "SUCCEEDED"
|
||||
body := map[string]any{"output": output, "usage": map[string]any{"image_count": 2}}
|
||||
encoded, err := common.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
task := &model.Task{TaskID: "task_public", Status: model.TaskStatusSuccess, Properties: model.Properties{OriginModelName: "alias", UpstreamModelName: tc.model}}
|
||||
task.SetData(body)
|
||||
adaptor := taskplugin.New(plugin)
|
||||
adaptor.Init(&relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{}})
|
||||
result, err := adaptor.ParseTaskResult(task, &http.Response{StatusCode: http.StatusOK}, encoded)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "SUCCESS", result.Status)
|
||||
assert.Equal(t, first, result.Url)
|
||||
require.Equal(t, map[string]any{"image_count": float64(2)}, result.UsageFacts)
|
||||
// A reservation for four images can settle to the two actual successes.
|
||||
cost, _, err := billingexpr.RunExprWithRequest(`tier("image", u("image_count") * 0.1)`, billingexpr.TokenParams{}, billingexpr.RequestInput{Usage: result.UsageFacts})
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, 0.2, cost, 1e-9)
|
||||
artifacts, err := adaptor.ListArtifacts(task)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, artifacts, 2)
|
||||
assert.Equal(t, "image-2", artifacts[1].Key)
|
||||
assert.Equal(t, "image", artifacts[1].Type)
|
||||
content, err := adaptor.BuildContentRequest(task, "image-2", relaychannel.TaskArtifactClientRequest{Method: http.MethodGet})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, second, content.URL)
|
||||
assert.True(t, content.Credentialless)
|
||||
assert.Empty(t, content.Headers)
|
||||
_, err = adaptor.BuildContentRequest(task, "image-3", relaychannel.TaskArtifactClientRequest{Method: http.MethodGet})
|
||||
require.Error(t, err)
|
||||
value, err := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "renderFinal"}, map[string]any{
|
||||
"artifacts": map[string]any{"image-1": map[string]any{"url": "/v1/tasks/task_public/artifacts/image-1/content"}, "image-2": map[string]any{"url": "/v1/tasks/task_public/artifacts/image-2/content"}},
|
||||
}, map[string]any{"status": "SUCCESS", "data": body})
|
||||
require.NoError(t, err)
|
||||
final := alibabaObject(t, value)
|
||||
message := final["output"].([]any)[0].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||
assert.Contains(t, message, "")
|
||||
assert.Contains(t, message, "")
|
||||
assert.NotContains(t, message, "cdn.example")
|
||||
if tc.model == "wan2.6-image" {
|
||||
assert.Contains(t, message, "Step 1\n\n![Image 1]")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("synchronous completion uses the host ID and retains result JSON", func(t *testing.T) {
|
||||
info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "wan2.6-t2i"}, TaskRelayInfo: &relaycommon.TaskRelayInfo{PublicTaskID: "task_public"}}
|
||||
adaptor := taskplugin.New(plugin)
|
||||
adaptor.Init(info)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
c.Set("task_request", map[string]any{"model": "wan2.6-t2i", "prompt": "a cat", "metadata": map[string]any{"upstream_mode": "sync"}})
|
||||
body := map[string]any{"request_id": "request_upstream", "output": map[string]any{"finished": true, "choices": []any{map[string]any{"message": map[string]any{"content": []any{map[string]any{"image": first}}}}}}, "usage": map[string]any{"image_count": 1}}
|
||||
encoded, err := common.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
parsed, taskErr := adaptor.ParseResponse(c, &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(encoded))}, info)
|
||||
require.Nil(t, taskErr)
|
||||
require.NotNil(t, parsed.Immediate)
|
||||
assert.Equal(t, "task_public", parsed.UpstreamTaskID)
|
||||
assert.Equal(t, "SUCCESS", parsed.Immediate.Status)
|
||||
assert.Equal(t, "100%", parsed.Immediate.Progress)
|
||||
assert.Equal(t, first, parsed.Immediate.Url)
|
||||
assert.JSONEq(t, string(encoded), string(parsed.TaskData))
|
||||
assert.Equal(t, map[string]any{"image_count": float64(1)}, parsed.Immediate.UsageFacts)
|
||||
value, err := plugin.Engine.CallPath(t.Context(), "native", []string{"imageCreated"}, map[string]any{}, map[string]any{"data": body})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, alibabaObject(t, body), alibabaObject(t, value))
|
||||
})
|
||||
|
||||
t.Run("asynchronous submission remains pending", func(t *testing.T) {
|
||||
value, err := plugin.Engine.Call(t.Context(), "parseSubmitResponse", map[string]any{"model": "wan2.6-t2i", "requestBody": map[string]any{"model": "wan2.6-t2i", "prompt": "a cat"}}, map[string]any{"body": map[string]any{"output": map[string]any{"task_id": "upstream_task", "task_status": "PENDING"}}})
|
||||
require.NoError(t, err)
|
||||
result := alibabaObject(t, value)
|
||||
assert.Equal(t, "upstream_task", result["taskId"])
|
||||
assert.NotContains(t, result, "immediate")
|
||||
})
|
||||
|
||||
t.Run("failed and malformed synchronous results do not complete a task", func(t *testing.T) {
|
||||
for _, body := range []map[string]any{
|
||||
{"code": "DataInspectionFailed", "message": "rejected"},
|
||||
{"output": map[string]any{"finished": true, "choices": []any{}}},
|
||||
{"output": map[string]any{"finished": false, "choices": []any{map[string]any{"message": map[string]any{"content": []any{map[string]any{"image": first}}}}}}},
|
||||
} {
|
||||
_, err := plugin.Engine.Call(t.Context(), "parseSubmitResponse", map[string]any{"upstreamModel": "wan2.6-t2i", "publicTaskId": "task_public", "requestBody": map[string]any{"model": "wan2.6-t2i", "prompt": "a cat", "metadata": map[string]any{"upstream_mode": "sync"}}}, map[string]any{"body": body})
|
||||
require.Error(t, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid completion counts retain the reservation", func(t *testing.T) {
|
||||
adaptor := taskplugin.New(plugin)
|
||||
task := &model.Task{Properties: model.Properties{UpstreamModelName: "wan2.6-t2i"}}
|
||||
for _, count := range []any{-1, 0, 1.5, 1e30, "2"} {
|
||||
encoded, err := common.Marshal(map[string]any{"output": map[string]any{"task_status": "SUCCEEDED", "choices": []any{map[string]any{"message": map[string]any{"content": []any{map[string]any{"image": first}}}}}}, "usage": map[string]any{"image_count": count}})
|
||||
require.NoError(t, err)
|
||||
result, err := adaptor.ParseTaskResult(task, &http.Response{StatusCode: http.StatusOK}, encoded)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "SUCCESS", result.Status)
|
||||
assert.Nil(t, result.UsageFacts, "invalid count %v must not replace the reservation", count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("interleaved text without images has zero image usage", func(t *testing.T) {
|
||||
body := map[string]any{"output": map[string]any{"task_status": "SUCCEEDED", "choices": []any{map[string]any{"message": map[string]any{"content": []any{map[string]any{"text": "Text-only answer"}}}}}}}
|
||||
encoded, err := common.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
adaptor := taskplugin.New(plugin)
|
||||
result, err := adaptor.ParseTaskResult(&model.Task{Properties: model.Properties{UpstreamModelName: "wan2.6-image"}}, &http.Response{StatusCode: http.StatusOK}, encoded)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "SUCCESS", result.Status)
|
||||
assert.Equal(t, map[string]any{"image_count": float64(0)}, result.UsageFacts)
|
||||
value, err := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "renderEvents"}, map[string]any{}, map[string]any{"status": "SUCCESS", "data": body}, nil)
|
||||
require.NoError(t, err)
|
||||
events := alibabaObject(t, value)
|
||||
assert.Equal(t, true, events["done"])
|
||||
assert.Equal(t, []any{map[string]any{"type": "output", "data": "Text-only answer"}}, events["events"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestAlibabaSynchronousMultiImageAndStream(t *testing.T) {
|
||||
plugin := newAlibabaPlugin(t)
|
||||
for _, streaming := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("SSE=%t", streaming), func(t *testing.T) {
|
||||
name := "wan2.7-image"
|
||||
parameters := map[string]any{"n": 4}
|
||||
if streaming {
|
||||
name = "wan2.6-image"
|
||||
parameters = map[string]any{"enable_interleave": true, "max_images": 3}
|
||||
}
|
||||
info := &relaycommon.RelayInfo{OriginModelName: name, ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: name, ChannelBaseUrl: "https://dashscope.aliyuncs.com"}, TaskRelayInfo: &relaycommon.TaskRelayInfo{PublicTaskID: "task_sync"}}
|
||||
adaptor := taskplugin.New(plugin)
|
||||
adaptor.Init(info)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
c.Set("task_request", map[string]any{"model": name, "prompt": "recipe", "metadata": map[string]any{"upstream_mode": "sync", "parameters": parameters}})
|
||||
require.Nil(t, adaptor.ValidateRequestAndSetAction(c, info))
|
||||
facts, err := adaptor.ExtractUsageFactsValidated(c, info)
|
||||
require.NoError(t, err)
|
||||
wantEstimate := float64(4)
|
||||
if streaming {
|
||||
wantEstimate = 3
|
||||
}
|
||||
assert.Equal(t, wantEstimate, facts["image_count"])
|
||||
var content []any
|
||||
if streaming {
|
||||
content = append(content, map[string]any{"type": "text", "text": "第一步"})
|
||||
}
|
||||
content = append(content, map[string]any{"type": "image", "image": "https://cdn.example/1.png"})
|
||||
if streaming {
|
||||
content = append(content, map[string]any{"type": "text", "text": "第二步"})
|
||||
}
|
||||
content = append(content, map[string]any{"type": "image", "image": "https://cdn.example/2.png"})
|
||||
body := map[string]any{"request_id": "vendor-request", "output": map[string]any{"finished": true, "choices": []any{map[string]any{"finish_reason": "stop", "message": map[string]any{"role": "assistant", "content": content}}}}, "usage": map[string]any{"image_count": 2}}
|
||||
encoded, err := common.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
responseType := "application/json"
|
||||
if streaming {
|
||||
var stream strings.Builder
|
||||
parts := []map[string]any{{"type": "text", "text": "第"}, {"type": "text", "text": "一步"}, {"type": "image", "image": "https://cdn.example/1.png"}, {"type": "text", "text": "第二步"}, {"type": "image", "image": "https://cdn.example/2.png"}}
|
||||
count := 0
|
||||
for index, part := range parts {
|
||||
if part["image"] != nil {
|
||||
count++
|
||||
}
|
||||
finish := "null"
|
||||
if index == len(parts)-1 {
|
||||
finish = "stop"
|
||||
}
|
||||
chunk, err := common.Marshal(map[string]any{"request_id": "vendor-request", "output": map[string]any{"finished": true, "choices": []any{map[string]any{"finish_reason": finish, "message": map[string]any{"role": "assistant", "content": []any{part}}}}}, "usage": map[string]any{"image_count": count}})
|
||||
require.NoError(t, err)
|
||||
stream.WriteString("data: ")
|
||||
stream.Write(chunk)
|
||||
stream.WriteString("\n\n")
|
||||
}
|
||||
encoded = []byte(stream.String())
|
||||
responseType = "text/event-stream"
|
||||
request := httptest.NewRequest("POST", "https://dashscope.aliyuncs.com", nil)
|
||||
require.NoError(t, adaptor.BuildRequestHeader(c, request, info))
|
||||
assert.Equal(t, "enable", request.Header.Get("X-DashScope-Sse"))
|
||||
}
|
||||
parsed, taskErr := adaptor.ParseResponse(c, &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {responseType}}, Body: io.NopCloser(bytes.NewReader(encoded))}, info)
|
||||
require.Nil(t, taskErr)
|
||||
require.NotNil(t, parsed.Immediate)
|
||||
assert.Equal(t, "SUCCESS", parsed.Immediate.Status)
|
||||
assert.Equal(t, map[string]any{"image_count": float64(2)}, parsed.Immediate.UsageFacts)
|
||||
want, err := common.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, string(want), string(parsed.TaskData))
|
||||
assert.False(t, c.Writer.Written())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,7 @@ func TestAlibabaWan3(t *testing.T) {
|
||||
parameters := roundTrip(t, value)["body"].(map[string]any)["parameters"].(map[string]any)
|
||||
assert.Equal(t, "720P", parameters["resolution"])
|
||||
assert.NotContains(t, parameters, "size")
|
||||
assert.Equal(t, "adaptive", parameters["ratio"])
|
||||
assert.Equal(t, "16:9", parameters["ratio"])
|
||||
|
||||
_, callErr = plugin.Engine.Call(t.Context(), "buildSubmitRequest", submitCtx("wan3.0-video", "wan3.0-video", map[string]any{"model": "wan3.0-video", "prompt": "a cat", "size": "1000*1000"}))
|
||||
require.ErrorContains(t, callErr, "invalid size")
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package plugins_test
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
@@ -11,6 +14,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/relay/channel"
|
||||
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"
|
||||
)
|
||||
@@ -42,6 +46,71 @@ func TestHailuoResponsesProtocol(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// Every model selects only the dimensions it can actually bill, while
|
||||
// submission still reports the union so prices saved against the previous
|
||||
// schema keep evaluating.
|
||||
func TestHailuoUsageProfilesPerModel(t *testing.T) {
|
||||
plugin := loadHailuoPlugin(t)
|
||||
families := []struct {
|
||||
models []string
|
||||
fields []string
|
||||
resolutions []string
|
||||
}{
|
||||
{
|
||||
models: []string{"MiniMax-H3"},
|
||||
fields: []string{"input_images", "input_video_seconds", "resolution", "seconds"},
|
||||
resolutions: []string{"768P", "2K"},
|
||||
},
|
||||
{
|
||||
models: []string{"MiniMax-Hailuo-2.3", "MiniMax-Hailuo-2.3-Fast"},
|
||||
fields: []string{"resolution", "seconds"},
|
||||
resolutions: []string{"768P", "1080P"},
|
||||
},
|
||||
{
|
||||
models: []string{"MiniMax-Hailuo-02"},
|
||||
fields: []string{"resolution", "seconds"},
|
||||
resolutions: []string{"512P", "768P", "1080P"},
|
||||
},
|
||||
{
|
||||
models: []string{"T2V-01-Director", "T2V-01", "I2V-01-Director", "I2V-01-live", "I2V-01", "S2V-01"},
|
||||
fields: []string{"seconds"},
|
||||
},
|
||||
}
|
||||
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)
|
||||
assert.NotEmpty(t, examples)
|
||||
})
|
||||
}
|
||||
}
|
||||
assert.ElementsMatch(t, plugin.Meta.Models, profiled, "every declared model must select a usage profile")
|
||||
|
||||
t.Run("submission keeps facts outside the selected profile billable", func(t *testing.T) {
|
||||
info := &relaycommon.RelayInfo{
|
||||
ChannelMeta: &relaycommon.ChannelMeta{ApiKey: "test-ak", ChannelBaseUrl: "https://api.minimax.example", UpstreamModelName: "MiniMax-Hailuo-2.3"},
|
||||
OriginModelName: "MiniMax-Hailuo-2.3",
|
||||
TaskRelayInfo: &relaycommon.TaskRelayInfo{PublicTaskID: "task_public"},
|
||||
}
|
||||
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", map[string]any{"model": "MiniMax-Hailuo-2.3", "prompt": "p", "duration": 10, "resolution": "768P"})
|
||||
require.Nil(t, adaptor.ValidateRequestAndSetAction(c, info))
|
||||
|
||||
facts, err := adaptor.ExtractUsageFactsValidated(c, info)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{
|
||||
"seconds": float64(10), "resolution": "768P", "input_images": float64(0), "input_video_seconds": float64(0),
|
||||
}, facts)
|
||||
})
|
||||
}
|
||||
|
||||
func TestHailuoArtifactContentProxy(t *testing.T) {
|
||||
source, err := builtinplugins.Source("hailuo")
|
||||
require.NoError(t, err)
|
||||
|
||||
+638
-147
@@ -1,47 +1,143 @@
|
||||
// Protocol capabilities, not prices. Keep defaults and validation shared by
|
||||
// native requests, compatibility protocols, and submission billing facts.
|
||||
const WAN_MODELS = {
|
||||
"wan3.0-video": { kind: "all", resolutions: ["480P", "720P", "1080P"], defaultResolution: "1080P", maxDuration: 30 },
|
||||
"wan3.0-video-prime": { kind: "all", resolutions: ["480P", "720P", "1080P"], defaultResolution: "1080P", maxDuration: 30 },
|
||||
"wan2.7-t2v": { kind: "t2v", resolutions: ["720P", "1080P"], defaultResolution: "1080P", maxDuration: 15 },
|
||||
"wan2.7-i2v": { kind: "media", resolutions: ["720P", "1080P"], defaultResolution: "1080P", maxDuration: 15 },
|
||||
"wan2.6-t2v": { kind: "size", resolutions: ["720P", "1080P"], defaultResolution: "1080P", maxDuration: 15 },
|
||||
"wan2.6-t2v-us": { kind: "size", resolutions: ["720P", "1080P"], defaultResolution: "1080P", durations: [5, 10, 15] },
|
||||
"wan2.6-i2v": { kind: "image", resolutions: ["720P", "1080P"], defaultResolution: "1080P", maxDuration: 15 },
|
||||
"wan2.6-i2v-flash": { kind: "image", resolutions: ["720P", "1080P"], defaultResolution: "1080P", maxDuration: 15 },
|
||||
"wan2.6-i2v-us": { kind: "image", resolutions: ["720P", "1080P"], defaultResolution: "1080P", durations: [5, 10, 15] },
|
||||
"wan2.5-t2v-preview": { kind: "size", resolutions: ["480P", "720P", "1080P"], defaultResolution: "1080P", durations: [5, 10] },
|
||||
"wan2.5-i2v-preview": { kind: "image", resolutions: ["480P", "720P", "1080P"], defaultResolution: "1080P", durations: [5, 10] },
|
||||
"wan2.2-t2v-plus": { kind: "size", resolutions: ["480P", "1080P"], defaultResolution: "1080P", durations: [5] },
|
||||
"wan2.2-i2v-flash": { kind: "image", resolutions: ["480P", "720P", "1080P"], defaultResolution: "720P", durations: [5] },
|
||||
"wan2.2-i2v-plus": { kind: "image", resolutions: ["480P", "1080P"], defaultResolution: "1080P", durations: [5] },
|
||||
"wan2.2-kf2v-flash": { kind: "frames", resolutions: ["480P", "720P", "1080P"], defaultResolution: "720P", durations: [5] },
|
||||
"wan2.2-s2v": { kind: "speech", resolutions: ["480P", "720P"], defaultResolution: "480P" },
|
||||
"wanx2.1-t2v-plus": { kind: "size", resolutions: ["720P"], defaultResolution: "720P", durations: [5] },
|
||||
"wanx2.1-t2v-turbo": { kind: "size", resolutions: ["480P", "720P"], defaultResolution: "720P", durations: [5] },
|
||||
"wanx2.1-i2v-plus": { kind: "image", resolutions: ["720P"], defaultResolution: "720P", durations: [5] },
|
||||
"wanx2.1-i2v-turbo": { kind: "image", resolutions: ["480P", "720P"], defaultResolution: "720P", durations: [3, 4, 5] },
|
||||
};
|
||||
|
||||
// HTTP contracts (SDK synchronous calls can also wrap polling):
|
||||
// https://help.aliyun.com/zh/model-studio/text-to-image-v2-api-reference
|
||||
// https://help.aliyun.com/zh/model-studio/wan-image-generation-api-reference
|
||||
// https://help.aliyun.com/zh/model-studio/wan-image-generation-and-editing-api-reference
|
||||
const WAN_IMAGE_MODELS = {
|
||||
"wan2.7-image-pro": "image27",
|
||||
"wan2.7-image": "image27",
|
||||
"wan2.6-image": "image26",
|
||||
"wan2.6-t2i": "t2i",
|
||||
"wan2.5-t2i-preview": "legacy",
|
||||
"wan2.2-t2i-flash": "legacy",
|
||||
"wan2.2-t2i-plus": "legacy",
|
||||
"wanx2.1-t2i-turbo": "legacy",
|
||||
"wanx2.1-t2i-plus": "legacy",
|
||||
"wanx2.0-t2i-turbo": "legacy",
|
||||
};
|
||||
|
||||
const WAN_IMAGE_USAGE_SCHEMA = {
|
||||
image_count: {
|
||||
type: "number",
|
||||
unit: "count",
|
||||
unitLabel: { en: "image", zh: "张", "zh-TW": "張", fr: "image", ja: "枚", ru: "изображение", vi: "ảnh" },
|
||||
description: { en: "Image generation unit price", zh: "图片生成单价" },
|
||||
},
|
||||
};
|
||||
|
||||
const WAN_VIDEO_USAGE_SCHEMA = {
|
||||
// Billable seconds: output duration, or input + output for Wan3.
|
||||
seconds: {
|
||||
type: "number",
|
||||
unit: "second",
|
||||
description: { en: "Video generation unit price", zh: "视频生成单价" },
|
||||
},
|
||||
// Requested output video resolution.
|
||||
resolution: {
|
||||
enum: ["480P", "720P", "1080P"],
|
||||
enumLabels: { "480P": { en: "480P", zh: "480P" }, "720P": { en: "720P", zh: "720P" }, "1080P": { en: "1080P", zh: "1080P" } },
|
||||
description: { en: "Output video resolution", zh: "输出视频分辨率" },
|
||||
},
|
||||
};
|
||||
|
||||
export const meta = {
|
||||
apiVersion: 1,
|
||||
key: "alibaba",
|
||||
name: "Alibaba Bailian",
|
||||
icon: "Bailian.Color",
|
||||
description: {
|
||||
en: "Alibaba Cloud Bailian Wanxiang video generation (text-to-video and image-to-video)",
|
||||
zh: "阿里云百炼万相视频生成(文生视频、图生视频)",
|
||||
en: "Alibaba Cloud Bailian Wanxiang image and video generation",
|
||||
zh: "阿里云百炼万相图片与视频生成",
|
||||
},
|
||||
version: "1.1.1",
|
||||
version: "1.3.0",
|
||||
author: { name: "QuantumNous" },
|
||||
channelTypes: [17],
|
||||
// Literal metadata also supports the dashboard's static script preview.
|
||||
models: [
|
||||
"wan3.0-video",
|
||||
"wan3.0-video-prime",
|
||||
"wan2.7-i2v",
|
||||
"wan2.7-t2v",
|
||||
"wan2.7-t2v-2026-04-25",
|
||||
"wan2.7-t2v-2026-06-12",
|
||||
"wan2.7-i2v",
|
||||
"wan2.7-i2v-2026-04-25",
|
||||
"wan2.6-t2v",
|
||||
"wan2.6-t2v-us",
|
||||
"wan2.6-i2v",
|
||||
"wan2.6-i2v-flash",
|
||||
"wan2.6-i2v-us",
|
||||
"wan2.5-t2v-preview",
|
||||
"wan2.5-i2v-preview",
|
||||
"wan2.2-t2v-plus",
|
||||
"wan2.2-i2v-flash",
|
||||
"wan2.2-i2v-plus",
|
||||
"wan2.2-kf2v-flash",
|
||||
"wan2.2-s2v",
|
||||
"wanx2.1-t2v-plus",
|
||||
"wanx2.1-t2v-turbo",
|
||||
"wanx2.1-i2v-plus",
|
||||
"wanx2.1-i2v-turbo",
|
||||
"wan2.7-image-pro",
|
||||
"wan2.7-image",
|
||||
"wan2.6-image",
|
||||
"wan2.6-t2i",
|
||||
"wan2.5-t2i-preview",
|
||||
"wan2.2-t2i-flash",
|
||||
"wan2.2-t2i-plus",
|
||||
"wanx2.1-t2i-turbo",
|
||||
"wanx2.1-t2i-plus",
|
||||
"wanx2.0-t2i-turbo",
|
||||
],
|
||||
fetchMode: "per_task",
|
||||
usageSchema: {
|
||||
// Requested video duration in seconds.
|
||||
seconds: {
|
||||
type: "number",
|
||||
unit: "second",
|
||||
description: { en: "Video generation unit price", zh: "视频生成单价" },
|
||||
submitResponseTypes: ["json", "sse"],
|
||||
requiredCapabilities: ["json-clone@1", "submit-sse-delta@1"],
|
||||
usageSchema: { ...WAN_IMAGE_USAGE_SCHEMA, ...WAN_VIDEO_USAGE_SCHEMA },
|
||||
usageProfiles: [
|
||||
{
|
||||
models: Object.keys(WAN_IMAGE_MODELS),
|
||||
schema: WAN_IMAGE_USAGE_SCHEMA,
|
||||
},
|
||||
// Requested output video resolution.
|
||||
resolution: {
|
||||
enum: ["480P", "720P", "1080P"],
|
||||
enumLabels: { "480P": { en: "480P", zh: "480P" }, "720P": { en: "720P", zh: "720P" }, "1080P": { en: "1080P", zh: "1080P" } },
|
||||
description: { en: "Output video resolution", zh: "输出视频分辨率" },
|
||||
{
|
||||
models: Object.keys(WAN_MODELS).concat(["wan2.7-t2v-2026-04-25", "wan2.7-t2v-2026-06-12", "wan2.7-i2v-2026-04-25"]),
|
||||
schema: WAN_VIDEO_USAGE_SCHEMA,
|
||||
},
|
||||
},
|
||||
],
|
||||
routes: [
|
||||
{ method: "POST", path: "/ali/api/v1/services/aigc/multimodal-generation/generation", type: "submit", models: ["wan2.7-image-pro", "wan2.7-image", "wan2.6-image", "wan2.6-t2i"], decode: "createImageTask", render: "imageCreated" },
|
||||
{ method: "POST", path: "/ali/api/v1/services/aigc/image-generation/generation", type: "submit", models: ["wan2.7-image-pro", "wan2.7-image", "wan2.6-image", "wan2.6-t2i"], decode: "createImageTask", render: "taskCreated" },
|
||||
{ method: "POST", path: "/ali/api/v1/services/aigc/text2image/image-synthesis", type: "submit", models: ["wan2.5-t2i-preview", "wan2.2-t2i-flash", "wan2.2-t2i-plus", "wanx2.1-t2i-turbo", "wanx2.1-t2i-plus", "wanx2.0-t2i-turbo"], decode: "createImageTask", render: "taskCreated" },
|
||||
{ method: "POST", path: "/ali/api/v1/services/aigc/video-generation/video-synthesis", type: "submit", decode: "createVideoTask", render: "taskCreated" },
|
||||
{ method: "POST", path: "/ali/api/v1/services/aigc/image2video/video-synthesis", type: "submit", decode: "createVideoTask", render: "taskCreated" },
|
||||
{ method: "GET", path: "/ali/api/v1/tasks/:task_id", type: "query", render: "taskStatus" },
|
||||
],
|
||||
protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
|
||||
protocols: [
|
||||
{ name: "openai_responses", supports: ["stream", "sync", "background"] },
|
||||
{ name: "openai_video", models: Object.keys(WAN_MODELS).concat(["wan2.7-t2v-2026-04-25", "wan2.7-t2v-2026-06-12", "wan2.7-i2v-2026-04-25"]) },
|
||||
],
|
||||
};
|
||||
|
||||
function trimmed(value) {
|
||||
@@ -64,129 +160,348 @@ function secondImage(req) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const SIZE_TO_RESOLUTION = {
|
||||
"832*480": "480P",
|
||||
"480*832": "480P",
|
||||
"624*624": "480P",
|
||||
"1280*720": "720P",
|
||||
"720*1280": "720P",
|
||||
"960*960": "720P",
|
||||
"1088*832": "720P",
|
||||
"832*1088": "720P",
|
||||
"1920*1080": "1080P",
|
||||
"1080*1920": "1080P",
|
||||
"1440*1440": "1080P",
|
||||
"1632*1248": "1080P",
|
||||
"1248*1632": "1080P",
|
||||
const LEGACY_SIZES = {
|
||||
"480P": { "16:9": "832*480", "9:16": "480*832", "1:1": "624*624" },
|
||||
"720P": { "16:9": "1280*720", "9:16": "720*1280", "1:1": "960*960", "4:3": "1088*832", "3:4": "832*1088" },
|
||||
"1080P": { "16:9": "1920*1080", "9:16": "1080*1920", "1:1": "1440*1440", "4:3": "1632*1248", "3:4": "1248*1632" },
|
||||
};
|
||||
|
||||
// Wan2.7 changed the pixel sizes for 4:3 and 3:4. Legacy sizes remain valid
|
||||
// compatibility inputs, but never replace the target model's native sizes.
|
||||
const MODERN_SIZES = {
|
||||
"720P": { "16:9": "1280*720", "9:16": "720*1280", "1:1": "960*960", "4:3": "1104*832", "3:4": "832*1104" },
|
||||
"1080P": { "16:9": "1920*1080", "9:16": "1080*1920", "1:1": "1440*1440", "4:3": "1648*1248", "3:4": "1248*1648" },
|
||||
};
|
||||
|
||||
function modelKey(model) {
|
||||
return String(model || "").replace(/-\d{4}-\d{2}-\d{2}$/, "").replace(/^wan2\.1-/, "wanx2.1-");
|
||||
}
|
||||
|
||||
function modelProfile(model) {
|
||||
const key = modelKey(model);
|
||||
if (!Object.prototype.hasOwnProperty.call(WAN_MODELS, key)) throw new Error("unsupported Wan model: " + model);
|
||||
return WAN_MODELS[key];
|
||||
}
|
||||
|
||||
function imageModel(ctx) {
|
||||
const key = modelKey(ctx.upstreamModel || ctx.model || (ctx.requestBody || {}).model);
|
||||
return Object.prototype.hasOwnProperty.call(WAN_IMAGE_MODELS, key) ? WAN_IMAGE_MODELS[key] : undefined;
|
||||
}
|
||||
|
||||
function convertImage(ctx) {
|
||||
const req = ctx.requestBody || {};
|
||||
const model = ctx.upstreamModel || req.model;
|
||||
const profile = imageModel(ctx);
|
||||
if (!profile) throw new Error("unsupported Wan image model: " + model);
|
||||
const metadata = objectValue(req.metadata, "metadata");
|
||||
if (metadata.model !== undefined && metadata.model !== model) throw new Error("can't change model with metadata");
|
||||
const mode = metadata.upstream_mode ?? "async";
|
||||
if (mode !== "sync" && mode !== "async") throw new Error("upstream_mode must be sync or async");
|
||||
if (profile === "legacy" && mode === "sync") throw new Error("this image model only supports asynchronous HTTP calls");
|
||||
const parameters = {};
|
||||
for (const key of ["n", "size", "negative_prompt", "prompt_extend", "watermark", "seed", "enable_interleave", "max_images", "enable_sequential", "thinking_mode", "bbox_list", "color_palette"]) {
|
||||
if (req[key] !== undefined) parameters[key] = req[key];
|
||||
}
|
||||
Object.assign(parameters, objectValue(metadata.parameters, "metadata.parameters"));
|
||||
for (const key of ["prompt_extend", "watermark", "enable_interleave", "enable_sequential", "thinking_mode", "stream"]) {
|
||||
if (parameters[key] !== undefined && typeof parameters[key] !== "boolean") throw new Error(key + " must be a boolean");
|
||||
}
|
||||
if (parameters.stream === true && (mode !== "sync" || profile !== "image26" || !parameters.enable_interleave))
|
||||
throw new Error("upstream streaming requires synchronous wan2.6-image interleaved output");
|
||||
if (parameters.enable_interleave && profile !== "image26") throw new Error("enable_interleave is only supported by wan2.6-image");
|
||||
if (parameters.enable_sequential && profile !== "image27") throw new Error("enable_sequential is only supported by wan2.7-image models");
|
||||
if (parameters.enable_interleave && mode === "sync") parameters.stream = true;
|
||||
const maxN = parameters.enable_interleave ? 1 : parameters.enable_sequential ? 12 : 4;
|
||||
parameters.n = parameters.n ?? (mode === "sync" || parameters.enable_interleave ? 1 : profile === "image27" ? (parameters.enable_sequential ? 12 : 1) : 4);
|
||||
// Provider limits are stricter than the host's dto.MaxImageN count ceiling.
|
||||
if (!Number.isInteger(parameters.n) || parameters.n < 1 || parameters.n > maxN) throw new Error("n must be an integer between 1 and " + maxN);
|
||||
if (parameters.max_images !== undefined && (!Number.isInteger(parameters.max_images) || parameters.max_images < 1 || parameters.max_images > 5))
|
||||
throw new Error("max_images must be an integer between 1 and 5");
|
||||
if (parameters.enable_interleave) parameters.max_images = parameters.max_images ?? 5;
|
||||
if (parameters.seed !== undefined && (!Number.isInteger(parameters.seed) || parameters.seed < 0 || parameters.seed > 2147483647))
|
||||
throw new Error("seed must be an integer between 0 and 2147483647");
|
||||
|
||||
const input = Object.assign({}, objectValue(metadata.input, "metadata.input"));
|
||||
if (profile === "legacy") {
|
||||
input.prompt = input.prompt ?? req.prompt;
|
||||
if (typeof input.prompt !== "string" || !input.prompt.trim()) throw new Error("prompt is required");
|
||||
if (input.messages !== undefined || firstImage(req)) throw new Error("this model only supports text-to-image input");
|
||||
if (parameters.negative_prompt !== undefined) {
|
||||
input.negative_prompt = parameters.negative_prompt;
|
||||
delete parameters.negative_prompt;
|
||||
}
|
||||
} else if (input.messages === undefined) {
|
||||
const content = [];
|
||||
if (req.images !== undefined && !Array.isArray(req.images)) throw new Error("images must be an array");
|
||||
const images = req.images || (firstImage(req) ? [firstImage(req)] : []);
|
||||
for (const image of images) content.push({ image: image });
|
||||
content.push({ text: req.prompt });
|
||||
input.messages = [{ role: "user", content: content }];
|
||||
}
|
||||
let imageCount = 0;
|
||||
if (profile !== "legacy") {
|
||||
if (!Array.isArray(input.messages) || input.messages.length !== 1 || !input.messages[0] || input.messages[0].role !== "user" || !Array.isArray(input.messages[0].content))
|
||||
throw new Error("input.messages must contain one user message with a content array");
|
||||
let texts = 0;
|
||||
for (const part of input.messages[0].content) {
|
||||
if (!part || typeof part !== "object" || Array.isArray(part)) throw new Error("invalid image message content");
|
||||
if (part.text !== undefined) {
|
||||
if (typeof part.text !== "string" || !part.text.trim()) throw new Error("text must be a non-empty string");
|
||||
texts++;
|
||||
}
|
||||
if (part.image !== undefined) {
|
||||
if (typeof part.image !== "string" || !/^(https?:\/\/|data:image\/)/i.test(part.image)) throw new Error("image must be an HTTP URL or Base64 data URL");
|
||||
imageCount++;
|
||||
}
|
||||
}
|
||||
if (texts !== 1) throw new Error("input.messages must contain exactly one text prompt");
|
||||
const maxInput = profile === "image27" ? 9 : profile === "image26" ? (parameters.enable_interleave ? 1 : 4) : 0;
|
||||
if (imageCount > maxInput) throw new Error("too many input images for this model");
|
||||
if (profile === "image26" && !parameters.enable_interleave && imageCount === 0) throw new Error("wan2.6-image editing requires a reference image; use wan2.6-t2i for text-to-image");
|
||||
}
|
||||
if (parameters.size !== undefined) {
|
||||
if (typeof parameters.size !== "string") throw new Error("size must be a string");
|
||||
parameters.size = parameters.size.replace(/x/i, "*");
|
||||
const supports4K = modelKey(model) === "wan2.7-image-pro" && !imageCount && !parameters.enable_sequential;
|
||||
if (["1K", "2K", "4K"].includes(parameters.size)) {
|
||||
if ((profile !== "image26" && profile !== "image27") || parameters.enable_interleave) throw new Error("this model requires a width*height image size");
|
||||
if (parameters.size === "4K" && !supports4K) throw new Error("4K is only supported by wan2.7-image-pro text-to-image without sequential output");
|
||||
} else {
|
||||
const match = /^(\d+)\*(\d+)$/.exec(parameters.size);
|
||||
if (!match) throw new Error("size must be width*height or a supported resolution preset");
|
||||
const width = Number(match[1]), height = Number(match[2]);
|
||||
const legacySize = profile === "legacy" && modelKey(model) !== "wan2.5-t2i-preview";
|
||||
const maxPixels = profile === "image27" ? (supports4K ? 4096 * 4096 : 2048 * 2048)
|
||||
: profile === "image26" ? (parameters.enable_interleave ? 1280 * 1280 : 2048 * 2048) : 1440 * 1440;
|
||||
const ratio = profile === "image27" ? 8 : 4;
|
||||
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0
|
||||
|| (legacySize ? width < 512 || height < 512 || width > 1440 || height > 1440
|
||||
: width * height > maxPixels || width / height > ratio || height / width > ratio))
|
||||
throw new Error("size is outside the model's pixel and aspect-ratio limits");
|
||||
}
|
||||
}
|
||||
const service = profile === "legacy" ? "text2image/image-synthesis" : mode === "sync" ? "multimodal-generation/generation" : "image-generation/generation";
|
||||
return {
|
||||
body: { model: model, input: input, parameters: parameters },
|
||||
service: service,
|
||||
synchronous: mode === "sync",
|
||||
action: imageCount ? "image_to_image" : "text_to_image",
|
||||
};
|
||||
}
|
||||
|
||||
function imageContent(body) {
|
||||
const output = (body && body.output) || {};
|
||||
const content = [];
|
||||
for (const result of Array.isArray(output.results) ? output.results : []) {
|
||||
if (result && typeof result.url === "string" && result.url) content.push({ image: result.url });
|
||||
}
|
||||
for (const choice of Array.isArray(output.choices) ? output.choices : []) {
|
||||
for (const part of (choice && choice.message && Array.isArray(choice.message.content)) ? choice.message.content : []) {
|
||||
if (part && typeof part.image === "string" && part.image) content.push({ image: part.image });
|
||||
else if (part && typeof part.text === "string") content.push({ text: part.text });
|
||||
}
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
function imageUsage(body) {
|
||||
const count = (body.usage || {}).image_count;
|
||||
const content = imageContent(body);
|
||||
const images = content.filter(function (part) { return part.image; });
|
||||
if (count !== undefined) {
|
||||
// Do not let fractional, negative or oversized upstream counts change billing.
|
||||
if (!Number.isInteger(count) || count < 0 || count > 12 || (images.length && count === 0)) throw new Error("invalid upstream image_count");
|
||||
return { image_count: count };
|
||||
}
|
||||
if (images.length > 12) throw new Error("too many output images");
|
||||
return content.length ? { image_count: images.length } : {};
|
||||
}
|
||||
|
||||
function objectValue(value, name) {
|
||||
if (value === undefined || value === null) return {};
|
||||
if (typeof value !== "object" || Array.isArray(value)) throw new Error(name + " must be an object");
|
||||
return value;
|
||||
}
|
||||
|
||||
// The host rejects negative canonical duration/seconds facts before any hook
|
||||
// runs, so wan3.0's "-1 = smart duration" sentinel travels as a boolean marker.
|
||||
function normalizeAutoDuration(req) {
|
||||
let auto = req.auto_duration === true;
|
||||
function normalizeRequest(value) {
|
||||
const req = Object.assign({}, value);
|
||||
const metadata = objectValue(req.metadata, "metadata");
|
||||
const parameters = Object.assign({}, objectValue(metadata.parameters, "metadata.parameters"));
|
||||
if (req.metadata !== undefined) {
|
||||
req.metadata = Object.assign({}, metadata, { parameters: parameters });
|
||||
objectValue(metadata.input, "metadata.input");
|
||||
}
|
||||
const duration = parameters.duration ?? req.duration ?? req.seconds ?? (req.auto_duration === true ? -1 : undefined);
|
||||
for (const key of ["duration", "seconds"]) {
|
||||
if (Number(req[key]) === -1) {
|
||||
auto = true;
|
||||
delete req[key];
|
||||
}
|
||||
if (Number(req[key]) === -1) delete req[key];
|
||||
}
|
||||
const parameters = req.metadata && req.metadata.parameters;
|
||||
if (parameters && Number(parameters.duration) === -1) {
|
||||
auto = true;
|
||||
delete parameters.duration;
|
||||
}
|
||||
if (auto) req.auto_duration = true;
|
||||
if (Number(parameters.duration) === -1) delete parameters.duration;
|
||||
if (Number(duration) === -1) {
|
||||
req.auto_duration = true;
|
||||
delete req.duration;
|
||||
delete req.seconds;
|
||||
} else delete req.auto_duration;
|
||||
// Normalize enum facts for host validation against the model's usage schema.
|
||||
if (req.resolution != null) req.resolution = normalizeResolution(req.resolution);
|
||||
if (parameters.resolution != null) parameters.resolution = normalizeResolution(parameters.resolution);
|
||||
return req;
|
||||
}
|
||||
|
||||
function normalizeResolution(value) {
|
||||
let resolution = String(value || "").toUpperCase();
|
||||
let resolution = trimmed(value).toUpperCase();
|
||||
if (!resolution) return "";
|
||||
if (!resolution.endsWith("P")) resolution += "P";
|
||||
return resolution;
|
||||
}
|
||||
|
||||
function videoSize(value) {
|
||||
const size = trimmed(value).replace(/x/i, "*");
|
||||
if (!size.includes("*")) return { resolution: normalizeResolution(size) };
|
||||
for (const sizes of [LEGACY_SIZES, MODERN_SIZES]) {
|
||||
for (const resolution of Object.keys(sizes)) {
|
||||
for (const ratio of Object.keys(sizes[resolution])) {
|
||||
if (sizes[resolution][ratio] === size) return { resolution: resolution, ratio: ratio };
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error("invalid size: " + size);
|
||||
}
|
||||
|
||||
function videoAction(req) {
|
||||
const input = objectValue((req.metadata || {}).input, "metadata.input");
|
||||
for (const source of [req, input]) {
|
||||
if (firstImage(source) || trimmed(source.img_url) || trimmed(source.image_url) || trimmed(source.first_frame_url) || (Array.isArray(source.media) && source.media.length))
|
||||
return "image_to_video";
|
||||
}
|
||||
return "text_to_video";
|
||||
}
|
||||
|
||||
function convert(ctx) {
|
||||
const req = ctx.requestBody;
|
||||
const upstreamModel = ctx.upstreamModel || req.model;
|
||||
const input = { prompt: req.prompt || "" };
|
||||
const profile = modelProfile(upstreamModel);
|
||||
const metadata = objectValue(req.metadata, "metadata");
|
||||
if (metadata.model !== undefined && metadata.model !== upstreamModel) throw new Error("can't change model with metadata");
|
||||
const input = {};
|
||||
for (const key of ["prompt", "negative_prompt", "img_url", "image_url", "first_frame_url", "last_frame_url", "audio_url", "template", "media"]) {
|
||||
if (req[key] !== undefined) input[key] = req[key];
|
||||
}
|
||||
const image = firstImage(req);
|
||||
if (image) input.img_url = image;
|
||||
const parameters = { prompt_extend: true, duration: 5 };
|
||||
if (image && input.img_url === undefined) input.img_url = image;
|
||||
Object.assign(input, objectValue(metadata.input, "metadata.input"));
|
||||
const nativeParameters = objectValue(metadata.parameters, "metadata.parameters");
|
||||
const parameters = profile.kind === "speech" ? {} : { prompt_extend: true };
|
||||
for (const key of ["resolution", "ratio", "prompt_extend", "watermark", "audio", "seed", "shot_type"]) {
|
||||
if (req[key] !== undefined) parameters[key] = req[key];
|
||||
}
|
||||
Object.assign(parameters, nativeParameters);
|
||||
for (const key of ["prompt_extend", "watermark", "audio"]) {
|
||||
if (parameters[key] != null && typeof parameters[key] !== "boolean") throw new Error(key + " must be a boolean");
|
||||
}
|
||||
if (parameters.seed != null && (!Number.isInteger(parameters.seed) || parameters.seed < (profile.kind === "all" ? -1 : 0) || parameters.seed > 2147483647))
|
||||
throw new Error("seed must be an integer between " + (profile.kind === "all" ? -1 : 0) + " and 2147483647");
|
||||
|
||||
if (req.size) {
|
||||
if (String(upstreamModel).includes("t2v") && !String(req.size).includes("*")) throw new Error("invalid size: " + req.size + ", example: 1920*1080");
|
||||
if (String(req.size).includes("*")) parameters.size = req.size;
|
||||
else parameters.resolution = normalizeResolution(req.size);
|
||||
} else if (String(upstreamModel).includes("t2v")) {
|
||||
parameters.size = String(upstreamModel).startsWith("wan2.5") || String(upstreamModel).startsWith("wan2.2") ? "1920*1080" : "1280*720";
|
||||
} else if (
|
||||
String(upstreamModel).startsWith("wan2.6") ||
|
||||
String(upstreamModel).startsWith("wan2.5") ||
|
||||
String(upstreamModel).startsWith("wan2.2-i2v-plus") ||
|
||||
String(upstreamModel).startsWith("wan3.0")
|
||||
) {
|
||||
parameters.resolution = "1080P";
|
||||
const sizeValue = nativeParameters.size ?? req.size;
|
||||
const size = sizeValue == null || sizeValue === "" ? {} : videoSize(sizeValue);
|
||||
let resolution = normalizeResolution(parameters.resolution ?? size.resolution ?? profile.defaultResolution);
|
||||
let ratio = parameters.ratio ?? size.ratio ?? (profile.kind === "all" ? "adaptive" : "16:9");
|
||||
// In the legacy T2V protocol the explicit native size is authoritative.
|
||||
if (profile.kind === "size" && nativeParameters.size != null && nativeParameters.size !== "") {
|
||||
resolution = size.resolution;
|
||||
ratio = size.ratio ?? parameters.ratio ?? "16:9";
|
||||
}
|
||||
if (!profile.resolutions.includes(resolution)) throw new Error(upstreamModel + " resolution must be one of " + profile.resolutions.join(", "));
|
||||
if (profile.kind === "size" || profile.kind === "t2v" || profile.kind === "all") {
|
||||
const ratios = ["16:9", "9:16", "1:1", "4:3", "3:4"];
|
||||
if (profile.kind === "all") ratios.push("adaptive");
|
||||
if (!ratios.includes(ratio)) throw new Error(upstreamModel + " ratio must be one of " + ratios.join(", "));
|
||||
}
|
||||
if (profile.kind === "size") {
|
||||
const pixelSize = LEGACY_SIZES[resolution][ratio];
|
||||
if (!pixelSize) throw new Error("unsupported ratio for " + resolution + ": " + ratio);
|
||||
parameters.size = pixelSize;
|
||||
delete parameters.resolution;
|
||||
delete parameters.ratio;
|
||||
} else {
|
||||
parameters.resolution = "720P";
|
||||
parameters.resolution = resolution;
|
||||
delete parameters.size;
|
||||
if (profile.kind === "t2v" || profile.kind === "all") parameters.ratio = ratio;
|
||||
else delete parameters.ratio; // Image-derived aspect ratios are not configurable.
|
||||
}
|
||||
|
||||
if (req.auto_duration === true) {
|
||||
if (!String(upstreamModel).startsWith("wan3.0")) throw new Error("duration -1 (smart duration) is only supported by wan3.0 models");
|
||||
parameters.duration = -1;
|
||||
} else if (Number(req.duration) > 0) parameters.duration = Number(req.duration);
|
||||
else if (req.seconds) {
|
||||
const seconds = Number(req.seconds);
|
||||
if (!Number.isInteger(seconds)) throw new Error("convert seconds to int failed");
|
||||
parameters.duration = seconds > 0 ? seconds : 5;
|
||||
const rawDuration = nativeParameters.duration ?? (req.auto_duration === true ? -1 : (req.duration ?? req.seconds));
|
||||
if (rawDuration != null && ((typeof rawDuration !== "number" && typeof rawDuration !== "string") || String(rawDuration).trim() === ""))
|
||||
throw new Error("duration must be a number");
|
||||
const duration = rawDuration == null ? 5 : Number(rawDuration);
|
||||
if (duration === -1 && profile.kind !== "all") throw new Error("duration -1 (smart duration) is only supported by wan3.0 models");
|
||||
if (profile.kind === "speech") {
|
||||
if (rawDuration != null && (!Number.isFinite(duration) || duration <= 0 || duration >= 20)) throw new Error("wan2.2-s2v duration must be positive and less than 20 seconds; output follows the audio");
|
||||
delete parameters.duration;
|
||||
} else {
|
||||
if (profile.durations) {
|
||||
if (!profile.durations.includes(duration)) throw new Error(upstreamModel + " duration must be one of " + profile.durations.join(", "));
|
||||
} else if (!(profile.kind === "all" && duration === -1) && (!Number.isInteger(duration) || duration < 2 || duration > profile.maxDuration)) {
|
||||
throw new Error(upstreamModel + " duration must be " + (profile.kind === "all" ? "-1 or " : "") + "an integer between 2 and " + profile.maxDuration);
|
||||
}
|
||||
parameters.duration = duration;
|
||||
}
|
||||
|
||||
const metadata = req.metadata || {};
|
||||
Object.assign(input, metadata.input || {});
|
||||
Object.assign(parameters, metadata.parameters || {});
|
||||
const model = metadata.model === undefined ? upstreamModel : metadata.model;
|
||||
if (model !== upstreamModel) throw new Error("can't change model with metadata");
|
||||
const body = { model: model, input: input, parameters: parameters };
|
||||
|
||||
if (String(model).startsWith("wan2.7-i2v") || String(model).startsWith("wan3.0-video")) {
|
||||
if (!Array.isArray(input.media) || input.media.length === 0) {
|
||||
if (profile.kind === "media" || profile.kind === "all") {
|
||||
if (input.media !== undefined && !Array.isArray(input.media)) throw new Error("input.media must be an array");
|
||||
if (!input.media || input.media.length === 0) {
|
||||
input.media = [];
|
||||
const first = trimmed(input.first_frame_url) || trimmed(input.img_url) || firstImage(req);
|
||||
const last = trimmed(input.last_frame_url) || secondImage(req);
|
||||
if (first) input.media.push({ type: "first_frame", url: first });
|
||||
if (last) input.media.push({ type: "last_frame", url: last });
|
||||
if (String(model).startsWith("wan2.7-i2v") && trimmed(input.audio_url)) input.media.push({ type: "driving_audio", url: input.audio_url });
|
||||
if (trimmed(input.audio_url)) input.media.push({ type: profile.kind === "media" ? "driving_audio" : "reference_audio", url: input.audio_url });
|
||||
}
|
||||
if (String(model).startsWith("wan2.7-i2v") && input.media.length === 0)
|
||||
throw new Error("wan2.7-i2v requires image, images, input_reference, or input.media");
|
||||
if (String(model).startsWith("wan3.0-video")) {
|
||||
if (input.media.length === 0) delete input.media;
|
||||
if (!trimmed(input.prompt) && !(Array.isArray(input.media) && input.media.length)) throw new Error("wan3.0-video requires prompt or input.media");
|
||||
if (parameters.size) {
|
||||
const mapped = SIZE_TO_RESOLUTION[parameters.size];
|
||||
if (!mapped) throw new Error("invalid size: " + parameters.size + ", wan3.0 accepts resolution 480P, 720P, or 1080P");
|
||||
parameters.resolution = mapped;
|
||||
delete parameters.size;
|
||||
}
|
||||
if (!parameters.resolution) parameters.resolution = "1080P";
|
||||
if (!parameters.ratio) parameters.ratio = "adaptive";
|
||||
const duration = Number(parameters.duration);
|
||||
if (duration !== -1 && (!Number.isInteger(duration) || duration < 2 || duration > 30))
|
||||
throw new Error("wan3.0 duration must be -1 or an integer between 2 and 30");
|
||||
const counts = {};
|
||||
const limits =
|
||||
profile.kind === "media"
|
||||
? { first_frame: 1, last_frame: 1, driving_audio: 1, first_clip: 1 }
|
||||
: { first_frame: 1, last_frame: 1, reference_image: 10, reference_video: 5, reference_audio: 5, file: 1, link: 1 };
|
||||
for (const media of input.media) {
|
||||
if (!media || !Object.prototype.hasOwnProperty.call(limits, media.type) || !trimmed(media.url)) throw new Error("invalid input.media type or url");
|
||||
counts[media.type] = (counts[media.type] || 0) + 1;
|
||||
if (counts[media.type] > limits[media.type]) throw new Error("too many input.media entries for " + media.type);
|
||||
}
|
||||
if (profile.kind === "media") {
|
||||
if ((!counts.first_frame && !counts.first_clip) || (counts.first_clip && (counts.first_frame || counts.driving_audio)))
|
||||
throw new Error("wan2.7-i2v requires first_frame or first_clip with a supported media combination");
|
||||
} else {
|
||||
const frames = counts.first_frame || counts.last_frame;
|
||||
const references = counts.reference_image || counts.reference_video || counts.reference_audio || counts.file || counts.link;
|
||||
if ((frames && references) || (counts.file && counts.link) || (counts.last_frame && !counts.first_frame)) throw new Error("unsupported wan3.0 input.media combination");
|
||||
if (!trimmed(input.prompt) && !input.media.length) throw new Error("wan3.0-video requires prompt or input.media");
|
||||
}
|
||||
if (!input.media.length) delete input.media;
|
||||
delete input.img_url;
|
||||
delete input.first_frame_url;
|
||||
delete input.last_frame_url;
|
||||
delete input.audio_url;
|
||||
} else if (profile.kind === "frames") {
|
||||
input.first_frame_url = trimmed(input.first_frame_url) || trimmed(input.img_url);
|
||||
if (!input.first_frame_url) throw new Error(upstreamModel + " requires first_frame_url or image");
|
||||
if (!input.last_frame_url && secondImage(req)) input.last_frame_url = secondImage(req);
|
||||
delete input.img_url;
|
||||
} else if (profile.kind === "speech") {
|
||||
input.image_url = trimmed(input.image_url) || trimmed(input.img_url);
|
||||
if (!input.image_url || !trimmed(input.audio_url)) throw new Error("wan2.2-s2v requires image_url and audio_url");
|
||||
delete input.img_url;
|
||||
if (!trimmed(input.prompt)) delete input.prompt;
|
||||
} else if (profile.kind === "image") {
|
||||
if (!trimmed(input.img_url)) throw new Error(upstreamModel + " requires img_url or image");
|
||||
} else if (!trimmed(input.prompt)) {
|
||||
throw new Error("input is required");
|
||||
}
|
||||
if (!parameters.prompt_extend) delete parameters.prompt_extend;
|
||||
if (!parameters.watermark) delete parameters.watermark;
|
||||
if (!parameters.seed) delete parameters.seed;
|
||||
for (const key of ["resolution", "size", "ratio"]) if (!parameters[key]) delete parameters[key];
|
||||
return body;
|
||||
return { model: upstreamModel, input: input, parameters: parameters };
|
||||
}
|
||||
|
||||
function resolutionRatio(body) {
|
||||
let resolution = body.parameters.size ? SIZE_TO_RESOLUTION[body.parameters.size] : normalizeResolution(body.parameters.resolution);
|
||||
const resolution = body.parameters.size ? videoSize(body.parameters.size).resolution : body.parameters.resolution;
|
||||
const ratios = {
|
||||
"wan3.0-video": { "480P": 1, "720P": 2, "1080P": 4 },
|
||||
"wan3.0-video-prime": { "480P": 1, "720P": 2, "1080P": 4 },
|
||||
@@ -199,7 +514,8 @@ function resolutionRatio(body) {
|
||||
"wan2.2-i2v-flash": { "480P": 1, "720P": 2 },
|
||||
"wan2.2-s2v": { "480P": 1, "720P": 1.8 },
|
||||
};
|
||||
return ratios[body.model] ? { key: "resolution-" + resolution, value: ratios[body.model][resolution] } : null;
|
||||
const model = modelKey(body.model);
|
||||
return ratios[model] ? { key: "resolution-" + resolution, value: ratios[model][resolution] } : null;
|
||||
}
|
||||
|
||||
function responsesInput(req) {
|
||||
@@ -244,51 +560,170 @@ function responsesVideoText(ctx) {
|
||||
const artifact = ctx && ctx.artifacts && ctx.artifacts.video;
|
||||
const url = trimmed(artifact && artifact.url);
|
||||
if (!url) throw new Error("video artifact is unavailable");
|
||||
const escaped = url.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||
const escaped = url.replace(/&/g, "&").replace(/\u0022/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||
return '<video controls src="' + escaped + '"></video>';
|
||||
}
|
||||
|
||||
function responsesOutputText(ctx, task) {
|
||||
const content = imageContent(artifactData(task));
|
||||
if (!content.length) return responsesVideoText(ctx);
|
||||
const parts = [];
|
||||
let index = 0;
|
||||
for (const part of content) {
|
||||
if (part.image) {
|
||||
const key = "image-" + (++index);
|
||||
const artifact = (ctx.artifacts || {})[key];
|
||||
if (!artifact || !artifact.url) throw new Error("image artifact is unavailable");
|
||||
parts.push("");
|
||||
} else parts.push(part.text);
|
||||
}
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
export function buildSubmitRequest(ctx) {
|
||||
if (imageModel(ctx)) {
|
||||
const converted = convertImage(ctx);
|
||||
const headers = { Authorization: "Bearer " + ctx.apiKey, "Content-Type": "application/json" };
|
||||
if (!converted.synchronous) headers["X-DashScope-Async"] = "enable";
|
||||
const streaming = converted.synchronous && converted.body.parameters.enable_interleave === true;
|
||||
if (streaming) headers["X-DashScope-Sse"] = "enable";
|
||||
return { url: ctx.baseUrl + "/api/v1/services/aigc/" + converted.service, method: "POST", headers: headers, body: converted.body, action: converted.action, responseType: streaming ? "sse" : "json" };
|
||||
}
|
||||
const body = convert(ctx);
|
||||
const kind = modelProfile(body.model).kind;
|
||||
const service = kind === "frames" || kind === "speech" ? "image2video" : "video-generation";
|
||||
return {
|
||||
url: ctx.baseUrl + "/api/v1/services/aigc/video-generation/video-synthesis",
|
||||
url: ctx.baseUrl + "/api/v1/services/aigc/" + service + "/video-synthesis",
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer " + ctx.apiKey, "Content-Type": "application/json", "X-DashScope-Async": "enable" },
|
||||
body: body,
|
||||
action: firstImage(ctx.requestBody) || (body.input && Array.isArray(body.input.media) && body.input.media.length) ? "image_to_video" : "text_to_video",
|
||||
action: videoAction(body.input),
|
||||
};
|
||||
}
|
||||
|
||||
// DashScope sends text/image deltas but cumulative usage. The host applies
|
||||
// generic JSON changes; vendor completion and usage semantics remain here.
|
||||
export function parseSubmitEventDelta(ctx, event, previousState) {
|
||||
const body = JSON.parse(event.data);
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("invalid DashScope stream event");
|
||||
if (body.code || event.event === "error") throw new Error((body.code || "upstream_error") + ": " + (body.message || "stream failed"));
|
||||
const state = utils.json.clone(previousState || { choices: [], hasUsage: false });
|
||||
const changes = [];
|
||||
if (!previousState) changes.push({ op: "set", path: [], value: { output: { choices: [], finished: false } } });
|
||||
if (body.request_id) changes.push({ op: "set", path: ["request_id"], value: body.request_id });
|
||||
if (body.usage) {
|
||||
if (!state.hasUsage) {
|
||||
changes.push({ op: "set", path: ["usage"], value: {} });
|
||||
state.hasUsage = true;
|
||||
}
|
||||
for (const key of Object.keys(body.usage)) changes.push({ op: "set", path: ["usage", key], value: body.usage[key] });
|
||||
}
|
||||
const choices = (body.output || {}).choices || [];
|
||||
for (let index = 0; index < choices.length; index++) {
|
||||
const choice = choices[index];
|
||||
if (!choice || !choice.message || !Array.isArray(choice.message.content)) throw new Error("invalid DashScope stream choice");
|
||||
if (!state.choices[index]) {
|
||||
state.choices.push({ count: 0, lastText: false, finishReason: "" });
|
||||
changes.push({ op: "append", path: ["output", "choices"], value: { message: { role: "assistant", content: [] } } });
|
||||
}
|
||||
const control = state.choices[index];
|
||||
for (const part of choice.message.content) {
|
||||
if (!part || typeof part !== "object") throw new Error("invalid DashScope stream content");
|
||||
if (typeof part.text === "string" && control.lastText) {
|
||||
changes.push({ op: "appendText", path: ["output", "choices", index, "message", "content", control.count - 1, "text"], value: part.text });
|
||||
} else {
|
||||
changes.push({ op: "append", path: ["output", "choices", index, "message", "content"], value: part });
|
||||
control.count++;
|
||||
control.lastText = typeof part.text === "string";
|
||||
}
|
||||
}
|
||||
if (choice.finish_reason && choice.finish_reason !== "null") {
|
||||
control.finishReason = choice.finish_reason;
|
||||
changes.push({ op: "set", path: ["output", "choices", index, "finish_reason"], value: choice.finish_reason });
|
||||
}
|
||||
}
|
||||
// Some intermediate frames carry finished:true; only terminal finish reasons
|
||||
// make the complete output ready for parsing and billing.
|
||||
const done =
|
||||
state.choices.length > 0 &&
|
||||
state.choices.every(function (choice) {
|
||||
return choice.finishReason === "stop";
|
||||
});
|
||||
changes.push({ op: "set", path: ["output", "finished"], value: done });
|
||||
return { changes: changes, state: state, done: done };
|
||||
}
|
||||
|
||||
export function parseSubmitResponse(ctx, resp) {
|
||||
const body = resp.body || {};
|
||||
if (body.code) throw new Error(body.code + ": " + (body.message || ""));
|
||||
if (imageModel(ctx) && convertImage(ctx).synchronous) {
|
||||
const content = imageContent(body);
|
||||
const images = content.filter(function (part) { return part.image; });
|
||||
const interleaved = convertImage(ctx).body.parameters.enable_interleave === true;
|
||||
if ((!images.length && !(interleaved && content.length)) || (body.output || {}).finished === false)
|
||||
throw new Error("synchronous image response has no completed output");
|
||||
// No vendor task exists for a synchronous HTTP response. The host already
|
||||
// allocated a public ID; retain that identity without inventing a pollable ID.
|
||||
const taskId = ctx.publicTaskId || body.request_id;
|
||||
if (!taskId) throw new Error("missing synchronous image request id");
|
||||
return { taskId: taskId, taskData: body, immediate: { status: "SUCCESS", progress: "100%", url: images.length ? images[0].image : "" } };
|
||||
}
|
||||
if (!body.output || !body.output.task_id) throw new Error("task_id is empty");
|
||||
return { taskId: body.output.task_id, taskData: body };
|
||||
}
|
||||
|
||||
export function extractUsage(ctx) {
|
||||
if (imageModel(ctx)) {
|
||||
const parameters = convertImage(ctx).body.parameters;
|
||||
return { image_count: parameters.enable_interleave ? parameters.max_images : parameters.n };
|
||||
}
|
||||
const body = convert(ctx);
|
||||
const duration = Number(body.parameters.duration);
|
||||
const seconds = Math.min(duration === -1 ? 30 : duration, 3600);
|
||||
const kind = modelProfile(body.model).kind;
|
||||
const hasVideo = (body.input.media || []).some(function (media) {
|
||||
return media.type === "reference_video";
|
||||
});
|
||||
let seconds = body.parameters.duration;
|
||||
if (kind === "speech") seconds = 20;
|
||||
else if (kind === "all" && (hasVideo || seconds === -1)) seconds = 30;
|
||||
if (ctx.usagePurpose === "billing_ratios") {
|
||||
const ratios = { seconds: seconds };
|
||||
const resolution = resolutionRatio(body);
|
||||
if (resolution && resolution.value !== undefined) ratios[resolution.key] = resolution.value;
|
||||
return ratios;
|
||||
}
|
||||
let resolution = body.parameters.size ? SIZE_TO_RESOLUTION[body.parameters.size] : normalizeResolution(body.parameters.resolution);
|
||||
if (!["480P", "720P", "1080P"].includes(resolution)) resolution = String(body.model).startsWith("wan3.0") ? "1080P" : "720P";
|
||||
const resolution = body.parameters.size ? videoSize(body.parameters.size).resolution : body.parameters.resolution;
|
||||
return { seconds: seconds, resolution: resolution };
|
||||
}
|
||||
|
||||
export function extractUsageOnSubmit(ctx, body) {
|
||||
// Legacy ratio pricing uses this hook; task expressions use the same actual
|
||||
// facts through extractUsageOnComplete for both immediate and polled results.
|
||||
return imageModel(ctx) && convertImage(ctx).synchronous ? imageUsage(body) : {};
|
||||
}
|
||||
|
||||
export function extractUsageOnComplete(task, taskResult, body) {
|
||||
if (imageModel(task)) return imageUsage(body || {});
|
||||
const output = (body && body.output) || {};
|
||||
const usage = (body && body.usage) || {};
|
||||
const facts = {};
|
||||
const seconds = Number(usage.output_video_duration || usage.duration || output.duration || output.duration_seconds || 0);
|
||||
if (Number.isFinite(seconds) && seconds > 0) facts.seconds = Math.min(seconds, 3600);
|
||||
const resolution = normalizeResolution(usage.SR || output.resolution || "");
|
||||
if (["480P", "720P", "1080P"].includes(resolution)) facts.resolution = resolution;
|
||||
const model = modelKey(task && (task.upstreamModel || task.model));
|
||||
if (model === "wan3.0-video" || model === "wan3.0-video-prime" || (!model && usage.input_video_duration != null)) {
|
||||
const inputSeconds = usage.input_video_duration;
|
||||
const outputSeconds = usage.output_video_duration ?? usage.duration;
|
||||
// Partial Wan3 statistics cannot establish the total billable duration.
|
||||
// Keep the reservation when either side is missing. Invalid supplied facts
|
||||
// reach the host validator unchanged, which logs and rejects them.
|
||||
if (inputSeconds != null && outputSeconds != null) {
|
||||
if (typeof inputSeconds !== "number" || !Number.isFinite(inputSeconds) || inputSeconds < 0) facts.seconds = inputSeconds;
|
||||
else if (typeof outputSeconds !== "number" || !Number.isFinite(outputSeconds) || outputSeconds < 0) facts.seconds = outputSeconds;
|
||||
else facts.seconds = inputSeconds + outputSeconds;
|
||||
}
|
||||
} else {
|
||||
const seconds = usage.duration ?? usage.output_video_duration ?? output.duration ?? output.duration_seconds;
|
||||
if (seconds != null) facts.seconds = seconds;
|
||||
}
|
||||
const resolution = usage.SR ?? output.resolution;
|
||||
if (resolution != null) facts.resolution = normalizeResolution(resolution);
|
||||
return facts;
|
||||
}
|
||||
|
||||
@@ -300,7 +735,18 @@ export function parseTaskResult(ctx, body) {
|
||||
const output = body.output || {};
|
||||
if (output.task_status === "PENDING") return { status: "QUEUED" };
|
||||
if (output.task_status === "RUNNING") return { status: "IN_PROGRESS" };
|
||||
if (output.task_status === "SUCCEEDED") return { status: "SUCCESS", url: output.video_url || "" };
|
||||
if (output.task_status === "SUCCEEDED") {
|
||||
if (imageModel(ctx)) {
|
||||
const content = imageContent(body);
|
||||
const images = content.filter(function (part) { return part.image; });
|
||||
if (!images.length) {
|
||||
if (imageModel(ctx) === "image26" && content.some(function (part) { return trimmed(part.text); })) return { status: "SUCCESS" };
|
||||
return { status: "FAILURE", reason: "image task succeeded without any images" };
|
||||
}
|
||||
return { status: "SUCCESS", url: images[0].image };
|
||||
}
|
||||
return { status: "SUCCESS", url: videoURL(body) };
|
||||
}
|
||||
if (["FAILED", "CANCELED", "UNKNOWN"].includes(output.task_status)) {
|
||||
let reason = body.message || "";
|
||||
if (!reason && output.message) reason = "task failed, code: " + (output.code || "") + " , message: " + output.message;
|
||||
@@ -316,41 +762,66 @@ function artifactData(ctx) {
|
||||
return data;
|
||||
}
|
||||
|
||||
function videoURL(body) {
|
||||
const output = (body && body.output) || {};
|
||||
return trimmed(output.video_url) || trimmed((output.results || {}).video_url);
|
||||
}
|
||||
|
||||
export function listArtifacts(task) {
|
||||
const output = artifactData(task).output || {};
|
||||
return task.status === "SUCCESS" && trimmed(output.video_url) ? [{ key: "video", type: "video" }] : [];
|
||||
if (task.status !== "SUCCESS") return [];
|
||||
const body = artifactData(task);
|
||||
const images = imageContent(body).filter(function (part) { return part.image; });
|
||||
if (images.length) return images.map(function (_, index) { return { key: "image-" + (index + 1), type: "image" }; });
|
||||
return videoURL(body) ? [{ key: "video", type: "video" }] : [];
|
||||
}
|
||||
|
||||
export function buildContentRequest(ctx) {
|
||||
if (ctx.artifactKey !== "video") throw new Error("artifact_not_found");
|
||||
const url = trimmed((artifactData(ctx).output || {}).video_url);
|
||||
let url;
|
||||
if (ctx.artifactKey === "video") url = videoURL(artifactData(ctx));
|
||||
else {
|
||||
const images = imageContent(artifactData(ctx)).filter(function (part) { return part.image; });
|
||||
const index = images.findIndex(function (_, index) { return ctx.artifactKey === "image-" + (index + 1); });
|
||||
url = index >= 0 ? images[index].image : "";
|
||||
}
|
||||
if (!url) throw new Error("artifact_not_found");
|
||||
return { url: url, method: ctx.clientRequest.method, credentialless: true };
|
||||
}
|
||||
|
||||
export const native = {
|
||||
createVideoTask: function (ctx) {
|
||||
if (!ctx.body || ctx.body.kind !== "json" || !ctx.body.value || Array.isArray(ctx.body.value)) throw new Error("JSON object required");
|
||||
const req = ctx.body.value,
|
||||
input = req.input || {},
|
||||
parameters = req.parameters || {};
|
||||
createImageTask: function (ctx) {
|
||||
if (!ctx.body || ctx.body.kind !== "json" || !ctx.body.value || typeof ctx.body.value !== "object" || Array.isArray(ctx.body.value)) throw new Error("JSON object required");
|
||||
const req = ctx.body.value;
|
||||
if (req.stream !== undefined && req.stream !== false) throw new Error("native task responses are aggregated JSON; configure upstream streaming in parameters");
|
||||
const requestBody = {
|
||||
model: req.model,
|
||||
prompt: input.prompt || "",
|
||||
image: input.img_url,
|
||||
duration: parameters.duration,
|
||||
size: parameters.size || parameters.resolution,
|
||||
metadata: {
|
||||
input: objectValue(req.input, "input"),
|
||||
parameters: objectValue(req.parameters, "parameters"),
|
||||
upstream_mode: String(ctx.path || "").includes("/multimodal-generation/") ? "sync" : "async",
|
||||
},
|
||||
};
|
||||
const hasMedia = Array.isArray(input.media) && input.media.length > 0;
|
||||
if (hasMedia || String(req.model).startsWith("wan3.0")) {
|
||||
requestBody.metadata = { input: hasMedia ? { media: input.media } : {}, parameters: Object.assign({}, parameters) };
|
||||
}
|
||||
normalizeAutoDuration(requestBody);
|
||||
return { kind: "submit", model: req.model, requestBody: requestBody };
|
||||
},
|
||||
imageCreated: function (ctx, task) {
|
||||
return task.data || {};
|
||||
},
|
||||
createVideoTask: function (ctx) {
|
||||
if (!ctx.body || ctx.body.kind !== "json" || !ctx.body.value || typeof ctx.body.value !== "object" || Array.isArray(ctx.body.value)) throw new Error("JSON object required");
|
||||
const req = ctx.body.value,
|
||||
input = objectValue(req.input, "input"),
|
||||
parameters = objectValue(req.parameters, "parameters");
|
||||
const requestBody = {
|
||||
model: req.model,
|
||||
metadata: { input: input, parameters: parameters },
|
||||
};
|
||||
if (input.prompt !== undefined) requestBody.prompt = input.prompt;
|
||||
const image = input.img_url || input.image_url || input.first_frame_url;
|
||||
if (image !== undefined) requestBody.image = image;
|
||||
return {
|
||||
kind: "submit",
|
||||
model: req.model,
|
||||
action: input.img_url || hasMedia ? "image_to_video" : "text_to_video",
|
||||
requestBody: requestBody,
|
||||
action: videoAction(requestBody),
|
||||
requestBody: normalizeRequest(requestBody),
|
||||
};
|
||||
},
|
||||
taskCreated: function (ctx, task) {
|
||||
@@ -378,7 +849,7 @@ export const protocols = {
|
||||
if (req.input !== undefined && typeof req.input !== "string" && !Array.isArray(req.input)) throw new Error("input must be a string or array");
|
||||
const input = responsesInput(req);
|
||||
const prompt = input.prompt || trimmed(req.prompt);
|
||||
const requestBody = { model: model, prompt: prompt };
|
||||
let requestBody = { model: model, prompt: prompt };
|
||||
if (trimmed(req.image)) requestBody.image = trimmed(req.image);
|
||||
if (req.images !== undefined && !Array.isArray(req.images)) throw new Error("images must be an array");
|
||||
const images = [];
|
||||
@@ -386,16 +857,28 @@ export const protocols = {
|
||||
for (const image of input.images) if (!images.includes(image)) images.push(image);
|
||||
if (images.length) requestBody.images = images;
|
||||
if (trimmed(req.input_reference)) requestBody.input_reference = trimmed(req.input_reference);
|
||||
for (const key of ["size", "duration", "seconds"]) {
|
||||
for (const key of [
|
||||
"size", "resolution", "ratio", "duration", "seconds", "auto_duration",
|
||||
"prompt_extend", "watermark", "audio", "seed", "shot_type", "negative_prompt",
|
||||
"img_url", "image_url", "first_frame_url", "last_frame_url", "audio_url", "template", "media",
|
||||
]) {
|
||||
if (Object.prototype.hasOwnProperty.call(req, key)) requestBody[key] = req[key];
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(req, "metadata")) requestBody.metadata = req.metadata;
|
||||
normalizeAutoDuration(requestBody);
|
||||
const hasMetaMedia = req.metadata && req.metadata.input && Array.isArray(req.metadata.input.media) && req.metadata.input.media.length > 0;
|
||||
const upstream = String(ctx.upstreamModel || model);
|
||||
const acceptsImageOnly = upstream.includes("i2v") || upstream.startsWith("wan3.0");
|
||||
if (!prompt && !hasMetaMedia && !(acceptsImageOnly && firstImage(requestBody))) throw new Error("input is required");
|
||||
return { kind: "submit", model: model, action: firstImage(requestBody) || hasMetaMedia ? "image_to_video" : "text_to_video", requestBody: requestBody };
|
||||
if (imageModel({ upstreamModel: ctx.upstreamModel, model: model })) {
|
||||
for (const key of ["n", "enable_interleave", "max_images", "enable_sequential", "thinking_mode", "bbox_list", "color_palette"]) {
|
||||
if (Object.prototype.hasOwnProperty.call(req, key)) requestBody[key] = req[key];
|
||||
}
|
||||
// The model may be a mapped alias; final validation runs after channel selection.
|
||||
return { kind: "submit", model: model, action: firstImage(requestBody) ? "image_to_image" : "text_to_image", requestBody: requestBody };
|
||||
}
|
||||
requestBody = normalizeRequest(requestBody);
|
||||
const action = videoAction(requestBody);
|
||||
const profile = WAN_MODELS[modelKey(ctx.upstreamModel || model)];
|
||||
const nativeInput = objectValue((requestBody.metadata || {}).input, "metadata.input");
|
||||
if (!trimmed(nativeInput.prompt ?? prompt) && (action === "text_to_video" || (profile && (profile.kind === "size" || profile.kind === "t2v"))))
|
||||
throw new Error("input is required");
|
||||
return { kind: "submit", model: model, action: action, requestBody: requestBody };
|
||||
},
|
||||
renderEvents: function (ctx, task, previousState) {
|
||||
const status = String(task.status || "UNKNOWN").toUpperCase();
|
||||
@@ -403,7 +886,7 @@ export const protocols = {
|
||||
const progress = Number.isFinite(value) && value >= 0 && value <= 100 ? value : null;
|
||||
const state = { status: status, progress: progress };
|
||||
if (status === "SUCCESS") {
|
||||
const text = responsesVideoText(ctx);
|
||||
const text = responsesOutputText(ctx, task);
|
||||
const events = previousState && previousState.status === status ? [] : text ? [{ type: "output", data: text }] : [];
|
||||
return { events: events, state: state, done: true };
|
||||
}
|
||||
@@ -417,7 +900,7 @@ export const protocols = {
|
||||
if (progress !== null) event.progress = progress;
|
||||
return { events: [event], state: state, done: false };
|
||||
},
|
||||
renderFinal: function (ctx, _task) {
|
||||
renderFinal: function (ctx, task) {
|
||||
return {
|
||||
output: [
|
||||
{
|
||||
@@ -427,7 +910,7 @@ export const protocols = {
|
||||
content: [
|
||||
{
|
||||
type: "output_text",
|
||||
text: responsesVideoText(ctx),
|
||||
text: responsesOutputText(ctx, task),
|
||||
annotations: [],
|
||||
logprobs: [],
|
||||
},
|
||||
@@ -468,12 +951,20 @@ export const protocols = {
|
||||
if (req.seconds !== undefined) req.seconds = Number(req.seconds);
|
||||
else if (req.duration !== undefined) req.seconds = Number(req.duration);
|
||||
if (req.duration !== undefined) req.duration = Number(req.duration);
|
||||
if (req.seed !== undefined) req.seed = Number(req.seed);
|
||||
for (const key of ["prompt_extend", "watermark", "audio", "auto_duration"]) {
|
||||
if (req[key] === undefined) continue;
|
||||
if (req[key] !== "true" && req[key] !== "false") throw new Error(key + " must be true or false");
|
||||
req[key] = req[key] === "true";
|
||||
}
|
||||
} else throw new Error("JSON or multipart body required");
|
||||
const requestBody = normalizeAutoDuration(Object.assign({}, req, { model: ctx.model }));
|
||||
if (!req || typeof req !== "object" || Array.isArray(req)) throw new Error("request body must be an object");
|
||||
if (req.images !== undefined && !Array.isArray(req.images)) throw new Error("images must be an array");
|
||||
const requestBody = normalizeRequest(Object.assign({}, req, { model: ctx.model }));
|
||||
return {
|
||||
kind: "submit",
|
||||
model: ctx.model,
|
||||
action: firstImage(requestBody) ? "image_to_video" : "text_to_video",
|
||||
action: videoAction(requestBody),
|
||||
requestBody: requestBody,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1,3 +1,58 @@
|
||||
// Billable dimensions differ per model family: only MiniMax-H3 charges for
|
||||
// input media, and each family renders its own set of output resolutions.
|
||||
const VIDEO_SECONDS_FIELD = {
|
||||
type: "number",
|
||||
unit: "second",
|
||||
description: { en: "Video generation unit price", zh: "视频生成单价" },
|
||||
};
|
||||
|
||||
const RESOLUTION_DESCRIPTION = { en: "Output video resolution", zh: "输出视频分辨率" };
|
||||
|
||||
// MiniMax-H3 allows duration 4 to 15 and renders 768P or 2K.
|
||||
const H3_USAGE_SCHEMA = {
|
||||
seconds: VIDEO_SECONDS_FIELD,
|
||||
resolution: {
|
||||
enum: ["768P", "2K"],
|
||||
description: RESOLUTION_DESCRIPTION,
|
||||
},
|
||||
// Input image count (estimated at submit, actual on completion).
|
||||
input_images: {
|
||||
type: "number",
|
||||
unit: "count",
|
||||
description: { en: "Input image unit price", zh: "输入图片单价" },
|
||||
},
|
||||
// Input video duration in seconds (reserved at the request maximum, actual on completion).
|
||||
input_video_seconds: {
|
||||
type: "number",
|
||||
unit: "second",
|
||||
description: { en: "Input video unit price", zh: "输入视频单价" },
|
||||
},
|
||||
};
|
||||
|
||||
// Hailuo 2.3 and 2.3-Fast allow duration 6 or 10 at 768P or 1080P.
|
||||
const HAILUO_23_USAGE_SCHEMA = {
|
||||
seconds: VIDEO_SECONDS_FIELD,
|
||||
resolution: {
|
||||
enum: ["768P", "1080P"],
|
||||
description: RESOLUTION_DESCRIPTION,
|
||||
},
|
||||
};
|
||||
|
||||
// Hailuo 02 keeps the 2.3 durations and adds 512P.
|
||||
const HAILUO_02_USAGE_SCHEMA = {
|
||||
seconds: VIDEO_SECONDS_FIELD,
|
||||
resolution: {
|
||||
enum: ["512P", "768P", "1080P"],
|
||||
description: RESOLUTION_DESCRIPTION,
|
||||
},
|
||||
};
|
||||
|
||||
// The 01 series renders 720P only, at a fixed duration of 6 seconds, so no
|
||||
// other dimension can change its price.
|
||||
const HAILUO_01_USAGE_SCHEMA = {
|
||||
seconds: VIDEO_SECONDS_FIELD,
|
||||
};
|
||||
|
||||
export const meta = {
|
||||
apiVersion: 1,
|
||||
key: "hailuo",
|
||||
@@ -7,7 +62,7 @@ export const meta = {
|
||||
en: "MiniMax Hailuo video generation (text-to-video, image-to-video, and MiniMax-H3 multimodal reference)",
|
||||
zh: "MiniMax 海螺视频生成(文生视频、图生视频、MiniMax-H3 多模态参考生视频)",
|
||||
},
|
||||
version: "1.1.3",
|
||||
version: "1.2.0",
|
||||
author: { name: "QuantumNous" },
|
||||
channelTypes: [35],
|
||||
models: [
|
||||
@@ -23,38 +78,14 @@ export const meta = {
|
||||
"S2V-01",
|
||||
],
|
||||
fetchMode: "per_task",
|
||||
usageSchema: {
|
||||
// Requested video duration in seconds. MiniMax-H3 allows 4 to 15; Hailuo 2.3/02/2.3-Fast allow 6 or 10; 01-series allow 6.
|
||||
seconds: {
|
||||
type: "number",
|
||||
unit: "second",
|
||||
description: { en: "Video generation unit price", zh: "视频生成单价" },
|
||||
},
|
||||
// Requested output video resolution.
|
||||
// Fallback for a channel alias that resolves to more than one model: the
|
||||
// union of every family's fields and resolutions.
|
||||
usageSchema: Object.assign({}, H3_USAGE_SCHEMA, {
|
||||
resolution: {
|
||||
enum: ["512P", "768P", "720P", "1080P", "2K"],
|
||||
enumLabels: {
|
||||
"512P": { en: "512P", zh: "512P" },
|
||||
"768P": { en: "768P", zh: "768P" },
|
||||
"720P": { en: "720P", zh: "720P" },
|
||||
"1080P": { en: "1080P", zh: "1080P" },
|
||||
"2K": { en: "2K", zh: "2K" },
|
||||
},
|
||||
description: { en: "Output video resolution", zh: "输出视频分辨率" },
|
||||
description: RESOLUTION_DESCRIPTION,
|
||||
},
|
||||
// H3 input image count (estimated at submit, actual on completion).
|
||||
input_images: {
|
||||
type: "number",
|
||||
unit: "count",
|
||||
description: { en: "Input image unit price", zh: "输入图片单价" },
|
||||
},
|
||||
// H3 input video duration in seconds (reserved at the request maximum, actual on completion).
|
||||
input_video_seconds: {
|
||||
type: "number",
|
||||
unit: "second",
|
||||
description: { en: "Input video unit price", zh: "输入视频单价" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
usageExamples: [
|
||||
{ label: "2.3/02 768P 6s", facts: { seconds: 6, resolution: "768P", input_images: 0, input_video_seconds: 0 } },
|
||||
{ label: "2.3/02 768P 10s", facts: { seconds: 10, resolution: "768P", input_images: 0, input_video_seconds: 0 } },
|
||||
@@ -66,6 +97,43 @@ export const meta = {
|
||||
{ label: "H3 2K 5s · 9 images", facts: { seconds: 5, resolution: "2K", input_images: 9, input_video_seconds: 0 } },
|
||||
{ label: "H3 2K 5s · input video", facts: { seconds: 5, resolution: "2K", input_images: 0, input_video_seconds: 15 } },
|
||||
],
|
||||
usageProfiles: [
|
||||
{
|
||||
models: ["MiniMax-H3"],
|
||||
schema: H3_USAGE_SCHEMA,
|
||||
examples: [
|
||||
{ label: "H3 768P 5s", facts: { seconds: 5, resolution: "768P", input_images: 0, input_video_seconds: 0 } },
|
||||
{ label: "H3 2K 5s", facts: { seconds: 5, resolution: "2K", input_images: 0, input_video_seconds: 0 } },
|
||||
{ label: "H3 2K 5s · 9 images", facts: { seconds: 5, resolution: "2K", input_images: 9, input_video_seconds: 0 } },
|
||||
{ label: "H3 2K 5s · input video", facts: { seconds: 5, resolution: "2K", input_images: 0, input_video_seconds: 15 } },
|
||||
],
|
||||
},
|
||||
{
|
||||
models: ["MiniMax-Hailuo-2.3", "MiniMax-Hailuo-2.3-Fast"],
|
||||
schema: HAILUO_23_USAGE_SCHEMA,
|
||||
examples: [
|
||||
{ label: "2.3 768P 6s", facts: { seconds: 6, resolution: "768P" } },
|
||||
{ label: "2.3 768P 10s", facts: { seconds: 10, resolution: "768P" } },
|
||||
{ label: "2.3 1080P 6s", facts: { seconds: 6, resolution: "1080P" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
models: ["MiniMax-Hailuo-02"],
|
||||
schema: HAILUO_02_USAGE_SCHEMA,
|
||||
examples: [
|
||||
{ label: "02 512P 6s", facts: { seconds: 6, resolution: "512P" } },
|
||||
{ label: "02 512P 10s", facts: { seconds: 10, resolution: "512P" } },
|
||||
{ label: "02 768P 6s", facts: { seconds: 6, resolution: "768P" } },
|
||||
{ label: "02 768P 10s", facts: { seconds: 10, resolution: "768P" } },
|
||||
{ label: "02 1080P 6s", facts: { seconds: 6, resolution: "1080P" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
models: ["T2V-01-Director", "T2V-01", "I2V-01-Director", "I2V-01-live", "I2V-01", "S2V-01"],
|
||||
schema: HAILUO_01_USAGE_SCHEMA,
|
||||
examples: [{ label: "01-series 720P 6s", facts: { seconds: 6 } }],
|
||||
},
|
||||
],
|
||||
protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ export const meta = {
|
||||
en: "OpenAI Sora video generation (text-to-video, image-to-video, and remix)",
|
||||
zh: "OpenAI Sora 视频生成(文生视频、图生视频、remix)",
|
||||
},
|
||||
version: "1.0.2",
|
||||
version: "1.0.3",
|
||||
channelTypes: [55, 1], // OpenAI-type channels natively serve sora with the same wire format
|
||||
author: { name: "QuantumNous" },
|
||||
models: ["sora-2", "sora-2-pro"],
|
||||
@@ -309,6 +309,7 @@ protocols.openai_video = {
|
||||
};
|
||||
},
|
||||
render: function (ctx, task) {
|
||||
if (task.data && typeof task.data === "object" && !Array.isArray(task.data)) return task.data;
|
||||
return legacyRenderers.openai_video(task);
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user