mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-08 01:17:09 +08:00
utils.fetch is a synchronous, read-only GET or HEAD to the channel Base URL host or the plugin's allowedHosts, through the channel proxy used for polling. It is available in buildSubmitRequest, the query and batch build and parse hooks, and buildContentRequest; every other hook throws. Each hook call gets at most 4 fetches, 15 s per fetch, 30 s in total and 1 MiB per body. Redirects are returned, not followed. responseType "bytes" returns an ArrayBuffer for binary data such as a media file header. While a fetch waits, the runtime gives up its execution slot and the watchdog pauses. BuildContentRequest now receives the request context, and the media proxy shares the plugin request header rules.
1390 lines
81 KiB
Go
1390 lines
81 KiB
Go
package plugins_test
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"maps"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/QuantumNous/new-api/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"
|
|
)
|
|
|
|
func TestAlibabaResponsesProtocol(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)
|
|
|
|
t.Run("claims every Ali model", func(t *testing.T) {
|
|
for _, model := range plugin.Meta.Models {
|
|
binding, found := registry.Generation().LookupEndpoint("POST", "/v1/responses", model)
|
|
require.True(t, found, model)
|
|
assert.Same(t, plugin, binding.Plugin)
|
|
assert.Equal(t, "openai_responses", binding.Protocol)
|
|
}
|
|
})
|
|
|
|
t.Run("selects image and video usage profiles for every declared model", func(t *testing.T) {
|
|
require.Len(t, plugin.Meta.UsageProfiles, 9)
|
|
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") || strings.Contains(name, "-i2i")
|
|
switch {
|
|
case strings.HasPrefix(name, "qwen-image-3.0"):
|
|
assert.ElementsMatch(t, []string{"image_count", "output_image_type", "input_image_count"}, keysOf(schema), name)
|
|
assert.Equal(t, plugin.Meta.UsageSchema["image_count"], schema["image_count"], name)
|
|
assert.Equal(t, []string{"qima_output_1k", "qima_output_2k"}, schema["output_image_type"].Enum, name)
|
|
case name == "z-image-turbo":
|
|
assert.ElementsMatch(t, []string{"image_count", "prompt_extend"}, keysOf(schema), name)
|
|
assert.Equal(t, "boolean", schema["prompt_extend"].Type, name)
|
|
case isImage:
|
|
assert.Equal(t, map[string]jsplugin.UsageFieldSchema{"image_count": plugin.Meta.UsageSchema["image_count"]}, schema, name)
|
|
case name == "wan2.6-i2v-flash":
|
|
assert.ElementsMatch(t, []string{"seconds", "resolution", "audio"}, keysOf(schema), name)
|
|
assert.Equal(t, plugin.Meta.UsageSchema["seconds"], schema["seconds"], name)
|
|
assert.Equal(t, []string{"720P", "1080P"}, schema["resolution"].Enum, name)
|
|
assert.Equal(t, "boolean", schema["audio"].Type, name)
|
|
default:
|
|
assert.ElementsMatch(t, []string{"seconds", "resolution"}, keysOf(schema), name)
|
|
assert.Equal(t, plugin.Meta.UsageSchema["seconds"], schema["seconds"], name)
|
|
assert.Subset(t, plugin.Meta.UsageSchema["resolution"].Enum, schema["resolution"].Enum, name)
|
|
}
|
|
for key, field := range schema {
|
|
assert.NotEmpty(t, field.Description["en"], name+" "+key)
|
|
assert.NotEmpty(t, field.Description["zh"], name+" "+key)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("declares documented usage facts", func(t *testing.T) {
|
|
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)
|
|
}
|
|
|
|
value, callErr := plugin.Engine.Call(t.Context(), "extractUsage", map[string]any{
|
|
"model": "wan2.5-i2v-preview",
|
|
"upstreamModel": "wan2.5-i2v-preview",
|
|
"usagePurpose": "facts",
|
|
"requestBody": map[string]any{
|
|
"model": "wan2.5-i2v-preview",
|
|
"duration": 10,
|
|
"size": "1080p",
|
|
"image": "https://cdn.example/first.png",
|
|
},
|
|
})
|
|
require.NoError(t, callErr)
|
|
encoded, marshalErr := common.Marshal(value)
|
|
require.NoError(t, marshalErr)
|
|
var facts map[string]any
|
|
require.NoError(t, common.Unmarshal(encoded, &facts))
|
|
assert.Equal(t, map[string]any{"seconds": float64(10), "resolution": "1080P"}, facts)
|
|
})
|
|
|
|
t.Run("parses text input and options", func(t *testing.T) {
|
|
value, callErr := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "decodeRequest"}, map[string]any{
|
|
"model": "wan2.7-t2v", "body": map[string]any{"kind": "json", "value": map[string]any{
|
|
"model": "wan2.7-t2v",
|
|
"input": "waves at sunset",
|
|
"size": "1280*720",
|
|
"duration": 6,
|
|
"metadata": map[string]any{"parameters": map[string]any{"watermark": true}},
|
|
}},
|
|
"stream": false,
|
|
})
|
|
require.NoError(t, callErr)
|
|
encoded, marshalErr := common.Marshal(value)
|
|
require.NoError(t, marshalErr)
|
|
var resolved map[string]any
|
|
require.NoError(t, common.Unmarshal(encoded, &resolved))
|
|
|
|
assert.Equal(t, map[string]any{
|
|
"kind": "submit",
|
|
"model": "wan2.7-t2v",
|
|
"action": "text_to_video",
|
|
"requestBody": map[string]any{
|
|
"model": "wan2.7-t2v",
|
|
"prompt": "waves at sunset",
|
|
"size": "1280*720",
|
|
"duration": float64(6),
|
|
"metadata": map[string]any{"parameters": map[string]any{"watermark": true}},
|
|
},
|
|
}, resolved)
|
|
})
|
|
|
|
t.Run("parses multimodal image input", func(t *testing.T) {
|
|
value, callErr := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "decodeRequest"}, map[string]any{
|
|
"model": "wan2.7-i2v", "body": map[string]any{"kind": "json", "value": map[string]any{
|
|
"model": "wan2.7-i2v",
|
|
"input": []any{
|
|
map[string]any{
|
|
"role": "user",
|
|
"content": []any{
|
|
map[string]any{"type": "input_text", "text": "animate between frames"},
|
|
map[string]any{"type": "input_image", "image_url": "https://cdn.example/first.png"},
|
|
map[string]any{"type": "input_image", "image_url": map[string]any{"url": "https://cdn.example/last.png"}},
|
|
},
|
|
},
|
|
},
|
|
}},
|
|
"stream": true,
|
|
})
|
|
require.NoError(t, callErr)
|
|
encoded, marshalErr := common.Marshal(value)
|
|
require.NoError(t, marshalErr)
|
|
var resolved map[string]any
|
|
require.NoError(t, common.Unmarshal(encoded, &resolved))
|
|
|
|
assert.Equal(t, "wan2.7-i2v", resolved["model"])
|
|
assert.Equal(t, "image_to_video", resolved["action"])
|
|
requestBody, ok := resolved["requestBody"].(map[string]any)
|
|
require.True(t, ok)
|
|
assert.Equal(t, "animate between frames", requestBody["prompt"])
|
|
assert.Equal(t, []any{"https://cdn.example/first.png", "https://cdn.example/last.png"}, requestBody["images"])
|
|
})
|
|
|
|
t.Run("accepts image-only i2v input", func(t *testing.T) {
|
|
value, callErr := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "decodeRequest"}, map[string]any{
|
|
"model": "wan2.7-i2v", "body": map[string]any{"kind": "json", "value": map[string]any{
|
|
"model": "wan2.7-i2v",
|
|
"input": []any{
|
|
map[string]any{"type": "input_image", "image_url": "https://cdn.example/first.png"},
|
|
},
|
|
}},
|
|
"stream": false,
|
|
})
|
|
require.NoError(t, callErr)
|
|
encoded, marshalErr := common.Marshal(value)
|
|
require.NoError(t, marshalErr)
|
|
var resolved map[string]any
|
|
require.NoError(t, common.Unmarshal(encoded, &resolved))
|
|
|
|
assert.Equal(t, "image_to_video", resolved["action"])
|
|
requestBody, ok := resolved["requestBody"].(map[string]any)
|
|
require.True(t, ok)
|
|
assert.Equal(t, "", requestBody["prompt"])
|
|
assert.Equal(t, []any{"https://cdn.example/first.png"}, requestBody["images"])
|
|
})
|
|
|
|
t.Run("rejects a request without input text", func(t *testing.T) {
|
|
_, callErr := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "decodeRequest"}, map[string]any{
|
|
"model": "wan2.7-t2v", "body": map[string]any{"kind": "json", "value": map[string]any{"model": "wan2.7-t2v"}},
|
|
"stream": false,
|
|
})
|
|
require.ErrorContains(t, callErr, "input is required")
|
|
})
|
|
|
|
protocolContext := map[string]any{
|
|
"requestBody": map[string]any{"model": "wan2.7-t2v"},
|
|
"stream": true,
|
|
"artifacts": map[string]any{
|
|
"video": map[string]any{
|
|
"key": "video",
|
|
"type": "video",
|
|
"mimeType": "video/mp4",
|
|
"url": "https://gateway.example/v1/tasks/task_public/artifacts/video/content?access=host%2Bcapability%3D",
|
|
},
|
|
},
|
|
}
|
|
successTask := map[string]any{
|
|
"task_id": "task_public",
|
|
"status": "SUCCESS",
|
|
"progress": "100%",
|
|
"created_at": 10,
|
|
"updated_at": 20,
|
|
"data": map[string]any{
|
|
"output": map[string]any{
|
|
"video_url": "https://upstream.example/video.mp4?Expires=1&Signature=must-not-leak",
|
|
},
|
|
},
|
|
}
|
|
|
|
t.Run("renders stream semantics accepted by the host", func(t *testing.T) {
|
|
progressValue, callErr := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "renderEvents"}, protocolContext, map[string]any{
|
|
"task_id": "task_public",
|
|
"status": "IN_PROGRESS",
|
|
"progress": "42%",
|
|
})
|
|
require.NoError(t, callErr)
|
|
progressResult, decodeErr := relay.DecodePluginProtocolEventResult(progressValue, relay.DefaultPluginProtocolLimits())
|
|
require.NoError(t, decodeErr)
|
|
require.Len(t, progressResult.Events, 1)
|
|
require.NotNil(t, progressResult.Events[0].Progress)
|
|
assert.Equal(t, float64(42), *progressResult.Events[0].Progress)
|
|
assert.False(t, progressResult.Done)
|
|
|
|
value, callErr := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "renderEvents"}, protocolContext, successTask)
|
|
require.NoError(t, callErr)
|
|
result, decodeErr := relay.DecodePluginProtocolEventResult(value, relay.DefaultPluginProtocolLimits())
|
|
require.NoError(t, decodeErr)
|
|
require.Len(t, result.Events, 1)
|
|
assert.Equal(t, "output", result.Events[0].Type)
|
|
assert.True(t, result.Done)
|
|
var text string
|
|
require.NoError(t, common.Unmarshal(result.Events[0].Data, &text))
|
|
assert.Equal(t, `<video controls src="https://gateway.example/v1/tasks/task_public/artifacts/video/content?access=host%2Bcapability%3D"></video>`, text)
|
|
assert.NotContains(t, text, "upstream.example")
|
|
|
|
machine := relay.NewPluginResponsesMachine("task_public", "wan2.7-t2v", 10, relay.DefaultPluginProtocolLimits())
|
|
_, machineErr := machine.CreatedEvent()
|
|
require.NoError(t, machineErr)
|
|
wireEvents, machineErr := machine.ApplyTick(result, "SUCCESS")
|
|
require.NoError(t, machineErr)
|
|
require.NotEmpty(t, wireEvents)
|
|
assert.Equal(t, "response.completed", wireEvents[len(wireEvents)-1].Type)
|
|
})
|
|
|
|
t.Run("renders a valid non-stream response", func(t *testing.T) {
|
|
value, callErr := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "renderFinal"}, protocolContext, successTask)
|
|
require.NoError(t, callErr)
|
|
machine := relay.NewPluginResponsesMachine("task_public", "wan2.7-t2v", 10, relay.DefaultPluginProtocolLimits())
|
|
response, finalErr := machine.FinalResponse(value, "SUCCESS")
|
|
require.NoError(t, finalErr)
|
|
assert.Equal(t, "resp_public", response["id"])
|
|
assert.Equal(t, "completed", response["status"])
|
|
|
|
output, ok := response["output"].([]any)
|
|
require.True(t, ok)
|
|
require.Len(t, output, 1)
|
|
item, ok := output[0].(map[string]any)
|
|
require.True(t, ok)
|
|
content, ok := item["content"].([]any)
|
|
require.True(t, ok)
|
|
require.Len(t, content, 1)
|
|
part, ok := content[0].(map[string]any)
|
|
require.True(t, ok)
|
|
text, ok := part["text"].(string)
|
|
require.True(t, ok)
|
|
assert.Equal(t, `<video controls src="https://gateway.example/v1/tasks/task_public/artifacts/video/content?access=host%2Bcapability%3D"></video>`, text)
|
|
assert.NotContains(t, text, "upstream.example")
|
|
metadata, ok := response["metadata"].(map[string]string)
|
|
require.True(t, ok)
|
|
assert.Equal(t, "ali", metadata["vendor"])
|
|
})
|
|
|
|
t.Run("does not fall back to the upstream URL when the host artifact is absent", func(t *testing.T) {
|
|
_, callErr := plugin.Engine.CallPath(
|
|
t.Context(),
|
|
"protocols",
|
|
[]string{"openai_responses", "renderFinal"},
|
|
map[string]any{
|
|
"requestBody": map[string]any{"model": "wan2.7-t2v"},
|
|
"stream": false,
|
|
},
|
|
successTask,
|
|
)
|
|
require.ErrorContains(t, callErr, "video artifact is unavailable")
|
|
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)
|
|
// The pricing table lists exactly the resolutions the model accepts.
|
|
schema, _ := plugin.Meta.UsageForModel(tc.model)
|
|
assert.Equal(t, tc.allowed, schema["resolution"].Enum)
|
|
wantFacts := map[string]any{"seconds": tc.seconds, "resolution": tc.resolution}
|
|
if tc.model == "wan2.6-i2v-flash" {
|
|
wantFacts["audio"] = true // priced separately for silent output; defaults to audio
|
|
}
|
|
assert.Equal(t, wantFacts, 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 TestAlibabaWanFlashAudioFact(t *testing.T) {
|
|
plugin := newAlibabaPlugin(t)
|
|
for _, tc := range []struct {
|
|
name string
|
|
request map[string]any
|
|
audio bool
|
|
}{
|
|
{"audio defaults to true", map[string]any{}, true},
|
|
{"explicit audio", map[string]any{"audio": true}, true},
|
|
{"silent output", map[string]any{"audio": false}, false},
|
|
{"native parameters win", map[string]any{"audio": true, "metadata": map[string]any{"parameters": map[string]any{"audio": false}}}, false},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
request := map[string]any{"model": "wan2.6-i2v-flash", "prompt": "a cat", "image": "https://cdn.example/first.png", "resolution": "720P", "duration": float64(5)}
|
|
maps.Copy(request, tc.request)
|
|
body, facts, _ := submitAlibabaRequest(t, plugin, "wan2.6-i2v-flash", request)
|
|
assert.Equal(t, map[string]any{"seconds": float64(5), "resolution": "720P", "audio": tc.audio}, facts)
|
|
parameters := body["parameters"].(map[string]any)
|
|
if _, explicit := tc.request["audio"]; explicit || tc.request["metadata"] != nil {
|
|
assert.Equal(t, tc.audio, parameters["audio"])
|
|
} else {
|
|
assert.NotContains(t, parameters, "audio")
|
|
}
|
|
})
|
|
}
|
|
// Other Wan models keep their two-fact schema even when a client sends audio.
|
|
_, facts, _ := submitAlibabaRequest(t, plugin, "wan2.6-i2v", map[string]any{"model": "wan2.6-i2v", "prompt": "a cat", "image": "https://cdn.example/first.png", "audio": false, "duration": float64(5)})
|
|
assert.Equal(t, map[string]any{"seconds": float64(5), "resolution": "1080P"}, facts)
|
|
}
|
|
|
|
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"}},
|
|
{"flash silent output settles as silent", "wan2.6-i2v-flash", map[string]any{"duration": 5, "SR": 720, "audio": false}, map[string]any{"seconds": float64(5), "resolution": "720P", "audio": false}},
|
|
{"flash without audio flag keeps the estimate", "wan2.6-i2v-flash", map[string]any{"duration": 5, "SR": 1080}, map[string]any{"seconds": float64(5), "resolution": "1080P"}},
|
|
{"audio flag is ignored for models priced without it", "wan2.6-i2v", map[string]any{"duration": 5, "SR": 1080, "audio": false}, map[string]any{"seconds": float64(5), "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"}, "only supports text-to-image input"},
|
|
{"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(t.Context(), 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(t.Context(), 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())
|
|
})
|
|
}
|
|
}
|
|
|
|
// The OpenAI Images protocol claims every declared image model on both image
|
|
// endpoints; chat and video models keep the built-in relay.
|
|
func TestAlibabaOpenAIImageProtocolBindings(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)
|
|
for _, name := range []string{"qwen-image-3.0-pro", "qwen-image-plus", "qwen-image-edit-plus", "z-image-turbo", "wan2.6-t2i", "wan2.2-t2i-flash", "wan2.5-i2i-preview", "wanx2.1-imageedit", "qwen-image-edit-max-2026-01-16"} {
|
|
for _, path := range []string{"/v1/images/generations", "/v1/images/edits"} {
|
|
binding, found := registry.Generation().LookupEndpoint(http.MethodPost, path, name)
|
|
require.True(t, found, name+" "+path)
|
|
assert.Same(t, plugin, binding.Plugin)
|
|
assert.Equal(t, "openai_image", binding.Protocol)
|
|
}
|
|
_, found := registry.Generation().LookupEndpoint(http.MethodPost, "/v1/videos", name)
|
|
assert.False(t, found, name)
|
|
}
|
|
for _, name := range []string{"wan2.6-t2v", "qwen-turbo"} {
|
|
_, found := registry.Generation().LookupEndpoint(http.MethodPost, "/v1/images/generations", name)
|
|
assert.False(t, found, name)
|
|
}
|
|
}
|
|
|
|
func decodeAlibabaImage(t *testing.T, plugin *jsplugin.LoadedPlugin, operation string, body map[string]any) (map[string]any, error) {
|
|
t.Helper()
|
|
path := "/v1/images/generations"
|
|
if operation == "edit" {
|
|
path = "/v1/images/edits"
|
|
}
|
|
value, err := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_image", "decodeRequest"}, map[string]any{
|
|
"protocol": "openai_image", "operation": operation, "model": body["model"], "path": path, "method": http.MethodPost,
|
|
"body": map[string]any{"kind": "json", "value": body},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return alibabaObject(t, value), nil
|
|
}
|
|
|
|
// OpenAI image requests map onto the DashScope services the models support,
|
|
// reserve the requested count, and reject counts the vendor would refuse
|
|
// before any quota is reserved.
|
|
func TestAlibabaOpenAIImageDecodeAndSubmission(t *testing.T) {
|
|
plugin := newAlibabaPlugin(t)
|
|
const base = "https://dashscope.aliyuncs.com/api/v1/services/aigc/"
|
|
for _, tc := range []struct {
|
|
name, model, operation string
|
|
body map[string]any
|
|
wantService, wantAsync string
|
|
wantAction string
|
|
wantN float64
|
|
wantFacts map[string]any
|
|
wantRatios map[string]any
|
|
}{
|
|
{"qwen-image-3.0-pro two images", "qwen-image-3.0-pro", "generate", map[string]any{"prompt": "a white siamese cat", "n": 2, "size": "1024x1024"},
|
|
"multimodal-generation/generation", "", "text_to_image", 2,
|
|
map[string]any{"image_count": float64(2), "output_image_type": "qima_output_1k", "input_image_count": float64(0)}, map[string]any{"image_count": float64(2)}},
|
|
{"qwen-image-3.0-pro without size reserves the 2K tier", "qwen-image-3.0", "generate", map[string]any{"prompt": "poster"},
|
|
"multimodal-generation/generation", "", "text_to_image", 1,
|
|
map[string]any{"image_count": float64(1), "output_image_type": "qima_output_2k", "input_image_count": float64(0)}, map[string]any{"image_count": float64(1)}},
|
|
{"fixed count model", "qwen-image-plus", "generate", map[string]any{"prompt": "a cat", "size": "1328x1328"},
|
|
"multimodal-generation/generation", "", "text_to_image", 1, map[string]any{"image_count": float64(1)}, map[string]any{"image_count": float64(1)}},
|
|
{"z-image with prompt rewriting", "z-image-turbo", "generate", map[string]any{"prompt": "a cat", "prompt_extend": true},
|
|
"multimodal-generation/generation", "", "text_to_image", 1,
|
|
map[string]any{"image_count": float64(1), "prompt_extend": true}, map[string]any{"image_count": float64(1), "prompt_extend_ratio": float64(2)}},
|
|
{"legacy text-to-image is asynchronous", "wan2.2-t2i-flash", "generate", map[string]any{"prompt": "a cat", "n": 2, "size": "1024x1024"},
|
|
"text2image/image-synthesis", "enable", "text_to_image", 2, map[string]any{"image_count": float64(2)}, map[string]any{"image_count": float64(2)}},
|
|
{"wan2.6 text-to-image is asynchronous by default", "wan2.6-t2i", "generate", map[string]any{"prompt": "a cat", "n": 2},
|
|
"image-generation/generation", "enable", "text_to_image", 2, map[string]any{"image_count": float64(2)}, map[string]any{"image_count": float64(2)}},
|
|
{"qwen edit with an image URL", "qwen-image-edit-plus", "edit", map[string]any{"prompt": "watercolor", "image": "https://cdn.example/input.png"},
|
|
"multimodal-generation/generation", "", "image_to_image", 1, map[string]any{"image_count": float64(1)}, map[string]any{"image_count": float64(1)}},
|
|
{"qwen-image-3.0 edit counts input images", "qwen-image-3.0-pro", "edit", map[string]any{"prompt": "watercolor", "image": []any{"https://cdn.example/1.png", "https://cdn.example/2.png"}, "size": "2048x2048"},
|
|
"multimodal-generation/generation", "", "image_to_image", 1,
|
|
map[string]any{"image_count": float64(1), "output_image_type": "qima_output_2k", "input_image_count": float64(2)}, map[string]any{"image_count": float64(1)}},
|
|
{"wan2.5 legacy edit is asynchronous", "wan2.5-i2i-preview", "edit", map[string]any{"prompt": "watercolor", "image": "https://cdn.example/input.png", "n": 2},
|
|
"image2image/image-synthesis", "enable", "image_to_image", 2, map[string]any{"image_count": float64(2)}, map[string]any{"image_count": float64(2)}},
|
|
{"wanx2.1 function edit is asynchronous", "wanx2.1-imageedit", "edit", map[string]any{"prompt": "make the hair red", "image": "https://cdn.example/input.png", "n": 2},
|
|
"image2image/image-synthesis", "enable", "image_to_image", 2, map[string]any{"image_count": float64(2)}, map[string]any{"image_count": float64(2)}},
|
|
{"provider parameters pass through", "qwen-image-2.0-pro", "generate", map[string]any{"prompt": "a cat", "n": 3, "parameters": map[string]any{"negative_prompt": "blurry", "watermark": false}},
|
|
"multimodal-generation/generation", "", "text_to_image", 3, map[string]any{"image_count": float64(3)}, map[string]any{"image_count": float64(3)}},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
body := map[string]any{"model": tc.model}
|
|
maps.Copy(body, tc.body)
|
|
resolved, err := decodeAlibabaImage(t, plugin, tc.operation, body)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "submit", resolved["kind"])
|
|
assert.Equal(t, tc.model, resolved["model"])
|
|
assert.Equal(t, tc.wantAction, resolved["action"])
|
|
request := resolved["requestBody"].(map[string]any)
|
|
upstreamBody, facts, url := submitAlibabaRequest(t, plugin, tc.model, request)
|
|
assert.Equal(t, base+tc.wantService, url)
|
|
assert.Equal(t, tc.model, upstreamBody["model"])
|
|
parameters := upstreamBody["parameters"].(map[string]any)
|
|
assert.Equal(t, tc.wantN, parameters["n"], "the reserved count is sent explicitly")
|
|
if size, ok := tc.body["size"].(string); ok {
|
|
assert.Equal(t, strings.Replace(size, "x", "*", 1), parameters["size"])
|
|
}
|
|
if extra, ok := tc.body["parameters"].(map[string]any); ok {
|
|
for key, expected := range alibabaObject(t, extra) {
|
|
assert.Equal(t, expected, parameters[key], key)
|
|
}
|
|
}
|
|
assert.Equal(t, tc.wantFacts, facts)
|
|
value, err := plugin.Engine.Call(t.Context(), "extractUsage", map[string]any{"upstreamModel": tc.model, "model": tc.model, "usagePurpose": "billing_ratios", "requestBody": request})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tc.wantRatios, alibabaObject(t, value))
|
|
value, err = plugin.Engine.Call(t.Context(), "buildSubmitRequest", map[string]any{"upstreamModel": tc.model, "requestBody": request, "baseUrl": "https://dashscope.aliyuncs.com", "apiKey": "k"})
|
|
require.NoError(t, err)
|
|
headers := alibabaObject(t, value)["headers"].(map[string]any)
|
|
if tc.wantAsync == "" {
|
|
assert.NotContains(t, headers, "X-DashScope-Async")
|
|
} else {
|
|
assert.Equal(t, tc.wantAsync, headers["X-DashScope-Async"])
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("multipart edits address every uploaded file", func(t *testing.T) {
|
|
value, err := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_image", "decodeRequest"}, map[string]any{
|
|
"protocol": "openai_image", "operation": "edit", "model": "qwen-image-edit-plus", "path": "/v1/images/edits", "method": http.MethodPost,
|
|
"body": map[string]any{"kind": "multipart",
|
|
"fields": map[string][]string{"model": {"qwen-image-edit-plus"}, "prompt": {"watercolor"}, "n": {"2"}, "response_format": {"b64_json"}, "watermark": {"false"}},
|
|
"files": []any{
|
|
map[string]any{"ref": "request_file:image[]", "field": "image[]", "filename": "a.png", "mimeType": "image/png", "size": 10},
|
|
map[string]any{"ref": "request_file:image[]#1", "field": "image[]", "filename": "b.jpg", "mimeType": "image/jpeg", "size": 10},
|
|
map[string]any{"ref": "request_file:mask", "field": "mask", "filename": "m.png", "mimeType": "image/png", "size": 10},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
resolved := alibabaObject(t, value)
|
|
assert.Equal(t, "image_to_image", resolved["action"])
|
|
request := resolved["requestBody"].(map[string]any)
|
|
assert.Equal(t, float64(2), request["n"])
|
|
assert.Equal(t, false, request["watermark"])
|
|
images := request["images"].([]any)
|
|
require.Len(t, images, 2, "mask uploads are not reference images")
|
|
assert.Equal(t, map[string]any{"__fileRef": "request_file:image[]", "encoding": "dataUrl", "mimeType": "image/png", "maxBytes": float64(10485760)}, images[0])
|
|
assert.Equal(t, "request_file:image[]#1", images[1].(map[string]any)["__fileRef"])
|
|
driver := map[string]any{"upstreamModel": "qwen-image-edit-plus", "model": "qwen-image-edit-plus", "requestBody": request, "baseUrl": "https://dashscope.aliyuncs.com", "apiKey": "k", "usagePurpose": "facts"}
|
|
value, err = plugin.Engine.Call(t.Context(), "buildSubmitRequest", driver)
|
|
require.NoError(t, err)
|
|
body := alibabaObject(t, value)["body"].(map[string]any)
|
|
content := body["input"].(map[string]any)["messages"].([]any)[0].(map[string]any)["content"].([]any)
|
|
require.Len(t, content, 3)
|
|
assert.Equal(t, "request_file:image[]#1", content[1].(map[string]any)["image"].(map[string]any)["__fileRef"], "placeholders reach the upstream body for the host to inline")
|
|
assert.Equal(t, "watercolor", content[2].(map[string]any)["text"])
|
|
value, err = plugin.Engine.Call(t.Context(), "extractUsage", driver)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, map[string]any{"image_count": float64(2)}, alibabaObject(t, value))
|
|
})
|
|
|
|
// wanx2.1-imageedit selects its operation with input.function. OpenAI edit
|
|
// fields map onto the documented body: the image becomes base_image_url, a
|
|
// mask becomes mask_image_url and selects local repainting, and the native
|
|
// route forwards the DashScope body unchanged.
|
|
t.Run("wanx2.1-imageedit maps OpenAI edit fields onto the function API", func(t *testing.T) {
|
|
const model = "wanx2.1-imageedit"
|
|
const image, mask = "https://cdn.example/base.png", "https://cdn.example/mask.png"
|
|
for _, tc := range []struct {
|
|
name string
|
|
body map[string]any
|
|
wantInput map[string]any
|
|
wantParameters map[string]any
|
|
}{
|
|
{"defaults to instruction editing", map[string]any{"prompt": "make the hair red", "image": image},
|
|
map[string]any{"prompt": "make the hair red", "function": "description_edit", "base_image_url": image}, map[string]any{"n": float64(1)}},
|
|
{"a mask selects local repainting", map[string]any{"prompt": "a ceramic rabbit", "image": image, "mask": mask, "n": 2},
|
|
map[string]any{"prompt": "a ceramic rabbit", "function": "description_edit_with_mask", "base_image_url": image, "mask_image_url": mask}, map[string]any{"n": float64(2)}},
|
|
{"explicit function with its parameters", map[string]any{"prompt": "a green fairy", "image": image, "function": "expand", "top_scale": 1.5, "watermark": true},
|
|
map[string]any{"prompt": "a green fairy", "function": "expand", "base_image_url": image}, map[string]any{"n": float64(1), "top_scale": 1.5, "watermark": true}},
|
|
} {
|
|
body := map[string]any{"model": model}
|
|
maps.Copy(body, tc.body)
|
|
resolved, err := decodeAlibabaImage(t, plugin, "edit", body)
|
|
require.NoError(t, err, tc.name)
|
|
upstreamBody, facts, url := submitAlibabaRequest(t, plugin, model, resolved["requestBody"].(map[string]any))
|
|
assert.Equal(t, base+"image2image/image-synthesis", url, tc.name)
|
|
assert.Equal(t, tc.wantInput, upstreamBody["input"], tc.name)
|
|
assert.Equal(t, tc.wantParameters, upstreamBody["parameters"], tc.name)
|
|
assert.Equal(t, map[string]any{"image_count": tc.wantParameters["n"]}, facts, tc.name)
|
|
}
|
|
|
|
native, err := plugin.Engine.CallPath(t.Context(), "native", []string{"createImageTask"}, map[string]any{
|
|
"path": "/ali/api/v1/services/aigc/image2image/image-synthesis",
|
|
"body": map[string]any{"kind": "json", "value": map[string]any{"model": model, "input": map[string]any{"function": "super_resolution", "prompt": "sharpen", "base_image_url": image}, "parameters": map[string]any{"upscale_factor": 2, "n": 1}}},
|
|
})
|
|
require.NoError(t, err)
|
|
upstreamBody, facts, url := submitAlibabaRequest(t, plugin, model, alibabaObject(t, native)["requestBody"].(map[string]any))
|
|
assert.Equal(t, base+"image2image/image-synthesis", url)
|
|
assert.Equal(t, map[string]any{"function": "super_resolution", "prompt": "sharpen", "base_image_url": image}, upstreamBody["input"])
|
|
assert.Equal(t, map[string]any{"upscale_factor": float64(2), "n": float64(1)}, upstreamBody["parameters"])
|
|
assert.Equal(t, map[string]any{"image_count": float64(1)}, facts)
|
|
|
|
value, err := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_image", "decodeRequest"}, map[string]any{
|
|
"protocol": "openai_image", "operation": "edit", "model": model, "path": "/v1/images/edits", "method": http.MethodPost,
|
|
"body": map[string]any{"kind": "multipart",
|
|
"fields": map[string][]string{"model": {model}, "prompt": {"a ceramic rabbit"}},
|
|
"files": []any{
|
|
map[string]any{"ref": "request_file:image", "field": "image", "filename": "a.png", "mimeType": "image/png", "size": 10},
|
|
map[string]any{"ref": "request_file:mask", "field": "mask", "filename": "m.png", "mimeType": "image/png", "size": 10},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
value, err = plugin.Engine.Call(t.Context(), "buildSubmitRequest", map[string]any{"upstreamModel": model, "requestBody": alibabaObject(t, value)["requestBody"], "baseUrl": "https://dashscope.aliyuncs.com", "apiKey": "k"})
|
|
require.NoError(t, err)
|
|
input := alibabaObject(t, value)["body"].(map[string]any)["input"].(map[string]any)
|
|
assert.Equal(t, "description_edit_with_mask", input["function"], "a mask upload selects local repainting")
|
|
assert.Equal(t, "request_file:image", input["base_image_url"].(map[string]any)["__fileRef"])
|
|
assert.Equal(t, "request_file:mask", input["mask_image_url"].(map[string]any)["__fileRef"], "the mask placeholder reaches the upstream body for the host to inline")
|
|
})
|
|
|
|
for _, tc := range []struct {
|
|
name, model, operation string
|
|
body map[string]any
|
|
decodeErr, submitErr string
|
|
}{
|
|
{"wanx2.1-imageedit takes exactly one image", "wanx2.1-imageedit", "edit", map[string]any{"prompt": "x", "image": []any{"https://cdn.example/1.png", "https://cdn.example/2.png"}}, "", "exactly one input image"},
|
|
{"wanx2.1-imageedit rejects unknown functions", "wanx2.1-imageedit", "edit", map[string]any{"prompt": "x", "image": "https://cdn.example/1.png", "function": "paint"}, "", "function must be one of"},
|
|
{"wanx2.1-imageedit local repainting needs a mask", "wanx2.1-imageedit", "edit", map[string]any{"prompt": "x", "image": "https://cdn.example/1.png", "function": "description_edit_with_mask"}, "", "requires a mask image"},
|
|
{"wanx2.1-imageedit has no size parameter", "wanx2.1-imageedit", "edit", map[string]any{"prompt": "x", "image": "https://cdn.example/1.png", "size": "1024x1024"}, "", "does not accept size"},
|
|
{"wanx2.1-imageedit bounds n", "wanx2.1-imageedit", "edit", map[string]any{"prompt": "x", "image": "https://cdn.example/1.png", "n": 5}, "", "n must be an integer between 1 and 4"},
|
|
{"fixed count model rejects n=2 before reservation", "qwen-image-plus", "generate", map[string]any{"prompt": "a cat", "n": 2}, "", "n must be 1 for this model"},
|
|
{"z-image rejects n=2", "z-image-turbo", "generate", map[string]any{"prompt": "a cat", "n": 2}, "", "n must be 1 for this model"},
|
|
{"qwen-image-3.0 bounds n", "qwen-image-3.0-pro", "generate", map[string]any{"prompt": "a cat", "n": 7}, "", "n must be an integer between 1 and 6"},
|
|
{"legacy bounds n", "wan2.2-t2i-flash", "generate", map[string]any{"prompt": "a cat", "n": 5}, "", "n must be an integer between 1 and 4"},
|
|
{"edit requires an image", "qwen-image-edit-plus", "edit", map[string]any{"prompt": "watercolor"}, "image is required", ""},
|
|
{"edit-only model requires an image on generations", "qwen-image-edit", "generate", map[string]any{"prompt": "watercolor"}, "", "requires at least one input image"},
|
|
{"text-to-image model rejects images", "qwen-image-plus", "edit", map[string]any{"prompt": "a cat", "image": "https://cdn.example/1.png"}, "", "only supports text-to-image input"},
|
|
{"streaming is rejected", "qwen-image-plus", "generate", map[string]any{"prompt": "a cat", "stream": true}, "stream is not supported", ""},
|
|
{"zero count is rejected", "qwen-image-3.0-pro", "generate", map[string]any{"prompt": "a cat", "n": 0}, "n must be a positive integer", ""},
|
|
{"response_format is validated", "qwen-image-plus", "generate", map[string]any{"prompt": "a cat", "response_format": "png"}, "response_format must be url or b64_json", ""},
|
|
{"prompt is required", "qwen-image-plus", "generate", map[string]any{}, "prompt is required", ""},
|
|
{"size bounds follow the model", "z-image-turbo", "generate", map[string]any{"prompt": "a cat", "size": "256x256"}, "", "pixel and aspect-ratio limits"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
body := map[string]any{"model": tc.model}
|
|
maps.Copy(body, tc.body)
|
|
resolved, err := decodeAlibabaImage(t, plugin, tc.operation, body)
|
|
if tc.decodeErr != "" {
|
|
require.ErrorContains(t, err, tc.decodeErr)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
request := resolved["requestBody"].(map[string]any)
|
|
_, err = plugin.Engine.Call(t.Context(), "buildSubmitRequest", map[string]any{"requestBody": request, "upstreamModel": tc.model})
|
|
require.ErrorContains(t, err, tc.submitErr)
|
|
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/images/generations", nil)
|
|
c.Set("task_request", request)
|
|
taskErr := adaptor.ValidateRequestAndSetAction(c, info)
|
|
require.NotNil(t, taskErr, "the production validator must reject the request before billing")
|
|
assert.Equal(t, http.StatusBadRequest, taskErr.StatusCode)
|
|
})
|
|
}
|
|
}
|
|
|
|
// Count derivation for synchronous image results: usage.image_count first,
|
|
// then the Qwen-Image-3.0 usage.output_image_count, then image payloads
|
|
// flattened across every choice; payload-less parts never count and an
|
|
// inconsistent count retains the reservation. The response renderer emits one
|
|
// OpenAI data entry per image, so the client sees as many images as are billed.
|
|
func TestAlibabaOpenAIImageCountDerivationAndRender(t *testing.T) {
|
|
plugin := newAlibabaPlugin(t)
|
|
const first, second = "https://cdn.example/first.png", "https://cdn.example/second.png"
|
|
twoImagesOneChoice := []any{map[string]any{"finish_reason": "stop", "message": map[string]any{"role": "assistant", "content": []any{map[string]any{"image": first}, map[string]any{"image": second}}}}}
|
|
for _, tc := range []struct {
|
|
name, model string
|
|
output map[string]any
|
|
usage map[string]any
|
|
wantFacts map[string]any
|
|
wantData int
|
|
wantErr bool
|
|
}{
|
|
{"qwen-image-3.0 reports output_image_count", "qwen-image-3.0-pro", map[string]any{"choices": twoImagesOneChoice},
|
|
map[string]any{"output_width": 1024, "output_height": 1024, "input_image_count": 0, "input_image_type": "qima_input_1k", "output_image_count": 2, "output_image_type": "qima_output_1k"},
|
|
map[string]any{"image_count": float64(2), "output_image_type": "qima_output_1k", "input_image_count": float64(0)}, 2, false},
|
|
{"one choice with two images and no usage counts payloads", "qwen-image-2.0-pro", map[string]any{"choices": twoImagesOneChoice}, nil,
|
|
map[string]any{"image_count": float64(2)}, 2, false},
|
|
{"usage.image_count is preferred", "wan2.6-t2i", map[string]any{"choices": twoImagesOneChoice}, map[string]any{"image_count": 2},
|
|
map[string]any{"image_count": float64(2)}, 2, false},
|
|
{"payload-less parts are not counted", "qwen-image-plus", map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": []any{map[string]any{"text": "revised"}, map[string]any{"image": first}, map[string]any{"image": ""}}}}}}, nil,
|
|
map[string]any{"image_count": float64(1)}, 1, false},
|
|
{"z-image keeps its count", "z-image-turbo", map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": []any{map[string]any{"image": first}}}}}}, map[string]any{"image_count": 1, "width": 1024, "height": 1536},
|
|
map[string]any{"image_count": float64(1)}, 1, false},
|
|
{"inconsistent zero count retains the reservation", "qwen-image-3.0-pro", map[string]any{"choices": twoImagesOneChoice}, map[string]any{"output_image_count": 0},
|
|
nil, 2, true},
|
|
{"fractional count retains the reservation", "wan2.6-t2i", map[string]any{"choices": twoImagesOneChoice}, map[string]any{"image_count": 1.5},
|
|
nil, 2, true},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
body := map[string]any{"request_id": "r", "output": tc.output}
|
|
if tc.usage != nil {
|
|
body["usage"] = tc.usage
|
|
}
|
|
request := map[string]any{"model": tc.model, "prompt": "a cat", "n": 2}
|
|
if tc.model == "qwen-image-plus" || tc.model == "z-image-turbo" {
|
|
request["n"] = 1
|
|
}
|
|
if strings.HasPrefix(tc.model, "wan") {
|
|
// Wan models default to the asynchronous service; the synchronous response shape needs the sync mode.
|
|
request["metadata"] = map[string]any{"upstream_mode": "sync"}
|
|
}
|
|
info := &relaycommon.RelayInfo{OriginModelName: tc.model, ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: tc.model, ChannelBaseUrl: "https://dashscope.aliyuncs.com"}, 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/images/generations", nil)
|
|
c.Set("task_request", request)
|
|
require.Nil(t, adaptor.ValidateRequestAndSetAction(c, info))
|
|
encoded, err := common.Marshal(body)
|
|
require.NoError(t, err)
|
|
parsed, taskErr := adaptor.ParseResponse(c, &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(bytes.NewReader(encoded))}, info)
|
|
require.Nil(t, taskErr)
|
|
require.NotNil(t, parsed.Immediate)
|
|
assert.Equal(t, "SUCCESS", parsed.Immediate.Status)
|
|
assert.Equal(t, "task_public", parsed.UpstreamTaskID, "synchronous results keep the host task id")
|
|
if tc.wantErr {
|
|
assert.Nil(t, parsed.Immediate.UsageFacts, "an unrecognized count must not replace the reservation")
|
|
} else {
|
|
assert.Equal(t, tc.wantFacts, parsed.Immediate.UsageFacts)
|
|
ratios := adaptor.AdjustBillingOnSubmit(info, parsed.TaskData)
|
|
assert.Equal(t, tc.wantFacts["image_count"], ratios["image_count"], "legacy per-call pricing settles to the same count")
|
|
}
|
|
value, err := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_image", "render"}, map[string]any{"model": tc.model}, map[string]any{"task_id": "task_public", "status": "SUCCESS", "created_at": 1700000000, "data": body})
|
|
require.NoError(t, err)
|
|
rendered := alibabaObject(t, value)
|
|
assert.Equal(t, float64(1700000000), rendered["created"])
|
|
data := rendered["data"].([]any)
|
|
require.Len(t, data, tc.wantData)
|
|
assert.Equal(t, first, data[0].(map[string]any)["url"])
|
|
assert.NotContains(t, data[0], "b64_json")
|
|
})
|
|
}
|
|
|
|
t.Run("legacy results and Base64 payloads render as OpenAI entries", func(t *testing.T) {
|
|
body := map[string]any{"output": map[string]any{"task_status": "SUCCEEDED", "results": []any{map[string]any{"url": first}, map[string]any{"code": "DataInspectionFailed"}, map[string]any{"url": "data:image/png;base64,QUFB"}}}, "usage": map[string]any{"image_count": 2}}
|
|
value, err := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_image", "render"}, map[string]any{}, map[string]any{"status": "SUCCESS", "created_at": 1, "data": body})
|
|
require.NoError(t, err)
|
|
rendered := alibabaObject(t, value)
|
|
data := rendered["data"].([]any)
|
|
require.Len(t, data, 2)
|
|
assert.Equal(t, map[string]any{"url": first}, data[0])
|
|
assert.Equal(t, map[string]any{"b64_json": "QUFB"}, data[1])
|
|
// metadata keeps the upstream body so clients still see DashScope usage.
|
|
assert.Equal(t, float64(2), rendered["metadata"].(map[string]any)["usage"].(map[string]any)["image_count"])
|
|
})
|
|
}
|