feat: publish v1.0.0-rc.36-custom.1

This commit is contained in:
Codex committed 2026-09-11 05:07:48 +08:00
commit 3a7e8f3b54
2381 files changed
+551494

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "../web/node_modules/oxfmt/configuration_schema.json",
"endOfLine": "lf",
"insertFinalNewline": true,
"printWidth": 160,
"quoteProps": "as-needed",
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5"
}
+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "../web/node_modules/oxlint/configuration_schema.json",
"plugins": ["oxc"],
"categories": {
"correctness": "error",
"suspicious": "warn"
},
"env": {
"builtin": true
},
"globals": {
"console": "readonly",
"utils": "readonly"
},
"rules": {
"eqeqeq": ["error", "always", { "null": "ignore" }],
"no-unused-vars": ["error", { "argsIgnorePattern": "^_", "caughtErrorsIgnorePattern": "^e$" }],
"no-underscore-dangle": ["warn", { "allow": ["__fileRef"] }],
"no-undef": "error",
"no-var": "error"
}
}
+252
View File
@@ -0,0 +1,252 @@
package plugins_test
import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/pkg/jsplugin"
builtinplugins "github.com/QuantumNous/new-api/plugins"
"github.com/QuantumNous/new-api/relay"
"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("declares documented usage facts", func(t *testing.T) {
require.Len(t, plugin.Meta.UsageSchema, 2)
for _, key := range []string{"seconds", "resolution"} {
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")
})
}
+139
View File
@@ -0,0 +1,139 @@
package plugins_test
import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/pkg/jsplugin"
builtinplugins "github.com/QuantumNous/new-api/plugins"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAlibabaWan3(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)
roundTrip := func(t *testing.T, value any) map[string]any {
encoded, marshalErr := common.Marshal(value)
require.NoError(t, marshalErr)
var decoded map[string]any
require.NoError(t, common.Unmarshal(encoded, &decoded))
return decoded
}
submitCtx := func(model, upstream string, body map[string]any) map[string]any {
return map[string]any{"model": model, "upstreamModel": upstream, "baseUrl": "https://dashscope.aliyuncs.com", "apiKey": "k", "requestBody": body}
}
usageCtx := func(purpose string, body map[string]any) map[string]any {
return map[string]any{"model": "wan3.0-video", "upstreamModel": "wan3.0-video", "usagePurpose": purpose, "requestBody": body}
}
decodeResponses := func(model string, body map[string]any) (map[string]any, error) {
value, callErr := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "decodeRequest"}, map[string]any{"model": model, "body": map[string]any{"kind": "json", "value": body}, "stream": false})
if callErr != nil {
return nil, callErr
}
return roundTrip(t, value), nil
}
t.Run("duration -1 becomes the auto_duration marker so the host accepts the body", func(t *testing.T) {
resolved, callErr := decodeResponses("wan3.0-video", map[string]any{"model": "wan3.0-video", "input": "a cat", "duration": -1})
require.NoError(t, callErr)
requestBody := resolved["requestBody"].(map[string]any)
assert.Equal(t, true, requestBody["auto_duration"])
assert.NotContains(t, requestBody, "duration")
value, callErr := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_video", "decodeRequest"}, map[string]any{"model": "wan3.0-video", "body": map[string]any{"kind": "json", "value": map[string]any{"model": "wan3.0-video", "prompt": "a cat", "seconds": -1}}})
require.NoError(t, callErr)
requestBody = roundTrip(t, value)["requestBody"].(map[string]any)
assert.Equal(t, true, requestBody["auto_duration"])
assert.NotContains(t, requestBody, "seconds")
assert.NotContains(t, requestBody, "duration")
value, callErr = plugin.Engine.CallPath(t.Context(), "native", []string{"createVideoTask"}, map[string]any{"body": map[string]any{"kind": "json", "value": map[string]any{
"model": "wan3.0-video", "input": map[string]any{"prompt": "a cat"}, "parameters": map[string]any{"duration": -1, "ratio": "16:9"},
}}})
require.NoError(t, callErr)
requestBody = roundTrip(t, value)["requestBody"].(map[string]any)
assert.Equal(t, true, requestBody["auto_duration"])
assert.NotContains(t, requestBody, "duration")
parameters := requestBody["metadata"].(map[string]any)["parameters"].(map[string]any)
assert.Equal(t, "16:9", parameters["ratio"])
assert.NotContains(t, parameters, "duration")
})
t.Run("auto_duration submits -1 upstream and bills 30 seconds up front", func(t *testing.T) {
body := map[string]any{"model": "wan3.0-video", "prompt": "a cat", "auto_duration": true}
value, callErr := plugin.Engine.Call(t.Context(), "buildSubmitRequest", submitCtx("wan3.0-video", "wan3.0-video", body))
require.NoError(t, callErr)
parameters := roundTrip(t, value)["body"].(map[string]any)["parameters"].(map[string]any)
assert.Equal(t, float64(-1), parameters["duration"])
value, callErr = plugin.Engine.Call(t.Context(), "extractUsage", usageCtx("facts", body))
require.NoError(t, callErr)
assert.Equal(t, map[string]any{"seconds": float64(30), "resolution": "1080P"}, roundTrip(t, value))
value, callErr = plugin.Engine.Call(t.Context(), "extractUsage", usageCtx("billing_ratios", body))
require.NoError(t, callErr)
assert.Equal(t, float64(30), roundTrip(t, value)["seconds"])
_, callErr = plugin.Engine.Call(t.Context(), "buildSubmitRequest", submitCtx("wan2.7-t2v", "wan2.7-t2v", map[string]any{"model": "wan2.7-t2v", "prompt": "a cat", "auto_duration": true}))
require.ErrorContains(t, callErr, "only supported by wan3.0")
})
t.Run("channel-mapped alias resolves defaults from the upstream model", func(t *testing.T) {
direct, callErr := plugin.Engine.Call(t.Context(), "buildSubmitRequest", submitCtx("wan3.0-video", "wan3.0-video", map[string]any{"model": "wan3.0-video", "prompt": "a cat"}))
require.NoError(t, callErr)
alias, callErr := plugin.Engine.Call(t.Context(), "buildSubmitRequest", submitCtx("my-wan3", "wan3.0-video", map[string]any{"model": "my-wan3", "prompt": "a cat"}))
require.NoError(t, callErr)
assert.Equal(t, roundTrip(t, direct)["body"], roundTrip(t, alias)["body"])
assert.Equal(t, "1080P", roundTrip(t, alias)["body"].(map[string]any)["parameters"].(map[string]any)["resolution"])
})
t.Run("size maps to a resolution tier and unknown sizes are rejected", func(t *testing.T) {
value, 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": "1280*720"}))
require.NoError(t, callErr)
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"])
_, 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")
_, callErr = plugin.Engine.Call(t.Context(), "buildSubmitRequest", submitCtx("wan3.0-video", "wan3.0-video", map[string]any{"model": "wan3.0-video", "prompt": "a cat", "duration": 31}))
require.ErrorContains(t, callErr, "between 2 and 30")
})
t.Run("image-only input stays rejected for t2v models and accepted for wan3.0", func(t *testing.T) {
imageOnly := []any{map[string]any{"type": "input_image", "image_url": "https://cdn.example/first.png"}}
_, callErr := decodeResponses("wan2.7-t2v", map[string]any{"model": "wan2.7-t2v", "input": imageOnly})
require.ErrorContains(t, callErr, "input is required")
resolved, callErr := decodeResponses("wan3.0-video", map[string]any{"model": "wan3.0-video", "input": imageOnly})
require.NoError(t, callErr)
assert.Equal(t, "image_to_video", resolved["action"])
value, callErr := plugin.Engine.Call(t.Context(), "buildSubmitRequest", submitCtx("wan3.0-video", "wan3.0-video", map[string]any{"model": "wan3.0-video", "prompt": "", "images": []any{"https://cdn.example/first.png"}}))
require.NoError(t, callErr)
input := roundTrip(t, value)["body"].(map[string]any)["input"].(map[string]any)
assert.Equal(t, []any{map[string]any{"type": "first_frame", "url": "https://cdn.example/first.png"}}, input["media"])
assert.NotContains(t, input, "img_url")
})
t.Run("completion facts read the wan3.0 usage block", func(t *testing.T) {
value, callErr := plugin.Engine.Call(t.Context(), "extractUsageOnComplete", map[string]any{}, map[string]any{}, map[string]any{
"output": map[string]any{"task_status": "SUCCEEDED", "video_url": "https://upstream.example/v.mp4"},
"usage": map[string]any{"video_count": 1, "duration": 7.5, "output_video_duration": 7.5, "SR": 720, "ratio": "16:9"},
})
require.NoError(t, callErr)
assert.Equal(t, map[string]any{"seconds": 7.5, "resolution": "720P"}, roundTrip(t, value))
value, callErr = plugin.Engine.Call(t.Context(), "extractUsageOnComplete", map[string]any{}, map[string]any{}, map[string]any{
"output": map[string]any{"task_status": "SUCCEEDED", "duration": 5, "resolution": "1080p"},
})
require.NoError(t, callErr)
assert.Equal(t, map[string]any{"seconds": float64(5), "resolution": "1080P"}, roundTrip(t, value))
})
}
+157
View File
@@ -0,0 +1,157 @@
package plugins
import (
"io/fs"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var expectedKeys = []string{"alibaba", "doubao", "google", "hailuo", "jimeng", "kling", "sora", "sunoapi", "vertex-ai", "vidu"}
func TestBuiltInVendorPluginsDeclareNativeRoutesAndLegacyChannelTypes(t *testing.T) {
generation := jsplugin.DefaultRegistry.Generation()
require.NotNil(t, generation)
routes := []struct {
method string
path string
key string
routeType jsplugin.RouteType
action string
renderer string
}{
{"POST", "/kling/v1/videos/text2video", "kling", jsplugin.RouteTypeSubmit, "text_to_video", "taskCreated"},
{"POST", "/kling/v1/videos/image2video", "kling", jsplugin.RouteTypeSubmit, "image_to_video", "taskCreated"},
{"GET", "/kling/v1/videos/text2video/:task_id", "kling", jsplugin.RouteTypeQuery, "", "taskStatus"},
{"GET", "/kling/v1/videos/image2video/:task_id", "kling", jsplugin.RouteTypeQuery, "", "taskStatus"},
{"POST", "/jimeng/", "jimeng", jsplugin.RouteTypeDynamic, "", "renderTask"},
{"POST", "/suno/submit/:action", "sunoapi", jsplugin.RouteTypeSubmit, "", "renderSubmit"},
{"POST", "/suno/fetch", "sunoapi", jsplugin.RouteTypeDynamic, "", "renderTasks"},
{"GET", "/suno/fetch/:task_id", "sunoapi", jsplugin.RouteTypeQuery, "", "renderTask"},
{"POST", "/doubao/api/v3/contents/generations/tasks", "doubao", jsplugin.RouteTypeSubmit, "", "taskCreated"},
{"GET", "/doubao/api/v3/contents/generations/tasks/:task_id", "doubao", jsplugin.RouteTypeQuery, "", "taskStatus"},
}
for _, expected := range routes {
t.Run(expected.method+" "+expected.path, func(t *testing.T) {
binding, found := generation.LookupDeclaredRoute(expected.method, expected.path)
require.True(t, found)
require.Equal(t, expected.key, binding.Plugin.Meta.Key)
require.Equal(t, expected.routeType, binding.Route.Type)
require.Equal(t, expected.action, binding.Route.Action)
require.Equal(t, expected.renderer, binding.Route.Render)
})
}
channelTypes := []struct {
value int
key string
}{
{1, "sora"},
{36, "sunoapi"},
{45, "doubao"},
{50, "kling"},
{51, "jimeng"},
{54, "doubao"},
{55, "sora"},
}
for _, channelType := range channelTypes {
plugin, found := generation.GetByChannelType(channelType.value)
require.True(t, found)
require.Equal(t, channelType.key, plugin.Meta.Key)
}
}
func TestBuiltInTaskPluginResponsesAndUsageContracts(t *testing.T) {
generation := jsplugin.DefaultRegistry.Generation()
require.NotNil(t, generation)
entries, err := fs.ReadDir(taskPlugins, "tasks")
require.NoError(t, err)
actualKeys := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
actualKeys = append(actualKeys, entry.Name())
}
}
assert.Equal(t, expectedKeys, actualKeys)
for _, key := range expectedKeys {
t.Run(key, func(t *testing.T) {
_, found := generation.Get(key)
require.True(t, found, "factory plugin was excluded from the active generation")
source, sourceErr := Source(key)
require.NoError(t, sourceErr)
registry := jsplugin.NewRegistry()
plugin, registerErr := registry.RegisterFactory(source, jsplugin.Options{Key: key})
require.NoError(t, registerErr)
var responsesClaim jsplugin.ProtocolClaim
foundResponses := false
for _, claim := range plugin.Meta.Protocols {
if claim.Name == "openai_responses" {
responsesClaim = claim
foundResponses = true
break
}
}
require.True(t, foundResponses, "openai_responses claim must be present")
assert.Equal(t, []string{"stream", "sync", "background"}, responsesClaim.Supports)
for _, model := range plugin.Meta.Models {
binding, claimed := registry.Generation().LookupEndpoint("POST", "/v1/responses", model)
require.True(t, claimed, model)
assert.Same(t, plugin, binding.Plugin)
}
for _, hook := range []string{"decodeRequest", "renderEvents", "renderFinal"} {
callable, callableErr := plugin.Engine.HasCallablePath(t.Context(), "protocols", "openai_responses", hook)
require.NoError(t, callableErr)
assert.True(t, callable, hook)
}
for _, hook := range []string{"extractUsage", "extractUsageOnComplete"} {
callable, callableErr := plugin.Engine.HasExport(t.Context(), hook)
require.NoError(t, callableErr)
assert.True(t, callable, hook)
}
require.NotEmpty(t, plugin.Meta.UsageSchema)
for usageKey, schema := range plugin.Meta.UsageSchema {
assert.NotEmpty(t, schema.Description, usageKey)
}
})
}
}
func TestBuiltInResponsesDecodersEchoChannelMappedAlias(t *testing.T) {
bodyOverrides := map[string]map[string]any{}
for _, key := range expectedKeys {
t.Run(key, func(t *testing.T) {
source, sourceErr := Source(key)
require.NoError(t, sourceErr)
registry := jsplugin.NewRegistry()
plugin, registerErr := registry.RegisterFactory(source, jsplugin.Options{Key: key})
require.NoError(t, registerErr)
require.NotEmpty(t, plugin.Meta.Models)
alias := "alias-under-test"
upstreamModel := plugin.Meta.Models[0]
body := map[string]any{"model": alias, "input": "a cat walking on the beach"}
if override, ok := bodyOverrides[key]; ok {
body = override
}
value, callErr := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "decodeRequest"}, map[string]any{
"model": alias, "upstreamModel": upstreamModel, "stream": false,
"body": map[string]any{"kind": "json", "value": body},
})
require.NoError(t, callErr)
encoded, marshalErr := common.Marshal(value)
require.NoError(t, marshalErr)
var decoded map[string]any
require.NoError(t, common.Unmarshal(encoded, &decoded))
assert.Equal(t, "submit", decoded["kind"])
assert.Equal(t, alias, decoded["model"])
})
}
}
+31
View File
@@ -0,0 +1,31 @@
package plugins_test
import "testing"
func TestDoubaoResponsesProtocol(t *testing.T) {
testVideoResponsesProtocol(t, videoResponsesTestCase{
pluginKey: "doubao",
model: "doubao-seedance-2-0-260128",
requestBody: map[string]any{
"model": "doubao-seedance-2-0-260128",
"input": []any{map[string]any{"role": "user", "content": []any{
map[string]any{"type": "input_text", "text": "a running fox"},
map[string]any{"type": "input_image", "image_url": "https://cdn.example/frame.png"},
}}},
"seconds": 6,
"size": "1920x1080",
},
wantAction: "image_to_video",
wantRequest: map[string]any{
"model": "doubao-seedance-2-0-260128",
"prompt": "a running fox",
"images": []any{"https://cdn.example/frame.png"},
"seconds": float64(6),
"metadata": map[string]any{
"resolution": "1080p",
},
},
wantUsageKeys: []string{"resolution", "tokens", "video_input"},
wantVendorName: "doubao",
})
}
+70
View File
@@ -0,0 +1,70 @@
package plugins
import (
"embed"
"encoding/base64"
"fmt"
"io/fs"
"github.com/QuantumNous/new-api/pkg/jsplugin"
)
//go:embed tasks
var taskPlugins embed.FS
func init() {
entries, err := fs.ReadDir(taskPlugins, "tasks")
if err != nil {
panic(fmt.Sprintf("read embedded task plugins: %v", err))
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
key := entry.Name()
source, sourceErr := Source(key)
if sourceErr != nil {
panic(fmt.Sprintf("read embedded task plugin %s: %v", key, sourceErr))
}
if _, registerErr := jsplugin.DefaultRegistry.RegisterFactory(source, jsplugin.Options{Key: key}); registerErr != nil {
panic(fmt.Sprintf("register embedded task plugin %s: %v", key, registerErr))
}
if mediaType, data, ok := Icon(key); ok {
if iconErr := jsplugin.ValidateIconImage(mediaType, data); iconErr != nil {
panic(fmt.Sprintf("embedded task plugin %s icon: %v", key, iconErr))
}
}
}
}
// Source returns the embedded factory source for a task plugin key.
func Source(key string) (string, error) {
source, err := taskPlugins.ReadFile("tasks/" + key + "/plugin.js")
if err != nil {
return "", err
}
return string(source), nil
}
// Icon returns the embedded sidecar logo for a factory plugin, if the plugin
// directory ships an icon.svg or icon.png next to plugin.js.
func Icon(key string) (mediaType string, data []byte, ok bool) {
if data, err := taskPlugins.ReadFile("tasks/" + key + "/icon.svg"); err == nil {
return "image/svg+xml", data, true
}
if data, err := taskPlugins.ReadFile("tasks/" + key + "/icon.png"); err == nil {
return "image/png", data, true
}
return "", nil, false
}
// IconDataURI returns the embedded factory logo in the same data URI form the
// task_plugins.icon column stores, so factory and override logos share one
// read path.
func IconDataURI(key string) string {
mediaType, data, ok := Icon(key)
if !ok {
return ""
}
return "data:" + mediaType + ";base64," + base64.StdEncoding.EncodeToString(data)
}
+30
View File
@@ -0,0 +1,30 @@
package plugins_test
import "testing"
func TestGoogleResponsesProtocol(t *testing.T) {
testVideoResponsesProtocol(t, videoResponsesTestCase{
pluginKey: "google",
model: "veo-3.1-fast-generate-preview",
requestBody: map[string]any{
"model": "veo-3.1-fast-generate-preview",
"input": []any{map[string]any{"role": "user", "content": []any{
map[string]any{"type": "input_text", "text": "animate this frame"},
map[string]any{"type": "input_image", "image_url": "data:image/png;base64,aGVsbG8="},
}}},
"seconds": 8,
"size": "1280x720",
},
wantAction: "image_to_video",
wantRequest: map[string]any{
"model": "veo-3.1-fast-generate-preview",
"prompt": "animate this frame",
"images": []any{"data:image/png;base64,aGVsbG8="},
"duration": float64(8),
"size": "1280x720",
"metadata": map[string]any{},
},
wantUsageKeys: []string{"resolution", "seconds"},
wantVendorName: "gemini",
})
}
+557
View File
@@ -0,0 +1,557 @@
package plugins_test
import (
"net/http"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/jsplugin"
builtinplugins "github.com/QuantumNous/new-api/plugins"
"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/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHailuoResponsesProtocol(t *testing.T) {
testVideoResponsesProtocol(t, videoResponsesTestCase{
pluginKey: "hailuo",
model: "MiniMax-Hailuo-2.3",
requestBody: map[string]any{
"model": "MiniMax-Hailuo-2.3",
"input": []any{map[string]any{"role": "user", "content": []any{
map[string]any{"type": "input_text", "text": "ocean at sunset"},
map[string]any{"type": "input_image", "image_url": "https://cdn.example/frame.png"},
}}},
"seconds": 10,
"size": "1920x1080",
},
wantAction: "image_to_video",
wantRequest: map[string]any{
"model": "MiniMax-Hailuo-2.3",
"prompt": "ocean at sunset",
"images": []any{"https://cdn.example/frame.png"},
"duration": float64(10),
"size": "1920x1080",
"metadata": map[string]any{"first_frame_image": "https://cdn.example/frame.png"},
},
wantUsageKeys: []string{"input_images", "input_video_seconds", "resolution", "seconds"},
wantVendorName: "hailuo",
})
}
func TestHailuoArtifactContentProxy(t *testing.T) {
source, err := builtinplugins.Source("hailuo")
require.NoError(t, err)
plugin, err := jsplugin.NewRegistry().RegisterFactory(source, jsplugin.Options{Key: "hailuo"})
require.NoError(t, err)
adaptor := taskplugin.New(plugin)
adaptor.Init(&relaycommon.RelayInfo{
ChannelMeta: &relaycommon.ChannelMeta{
ApiKey: "test-ak",
ChannelBaseUrl: "https://api.minimax.example",
},
})
data, err := common.Marshal(map[string]any{"file_id": "file/with space"})
require.NoError(t, err)
task := &model.Task{TaskID: "task-public", Status: model.TaskStatusSuccess, Data: data}
artifacts, err := adaptor.ListArtifacts(task)
require.NoError(t, err)
assert.Equal(t, []channel.TaskArtifact{{Key: "video", Type: "video", MimeType: "video/mp4"}}, artifacts)
descriptor, err := adaptor.BuildContentRequest(task, "video", channel.TaskArtifactClientRequest{Method: http.MethodHead})
require.NoError(t, err)
require.NotNil(t, descriptor)
assert.Equal(t, "https://api.minimax.example/v1/files/download?file_id=file%2Fwith%20space", descriptor.URL)
assert.Equal(t, http.MethodHead, descriptor.Method)
assert.Equal(t, map[string]string{"Accept": "video/*", "Authorization": "Bearer test-ak"}, descriptor.Headers)
assert.False(t, descriptor.Credentialless)
}
func loadHailuoPlugin(t *testing.T) *jsplugin.LoadedPlugin {
t.Helper()
source, err := builtinplugins.Source("hailuo")
require.NoError(t, err)
plugin, err := jsplugin.NewRegistry().RegisterFactory(source, jsplugin.Options{Key: "hailuo"})
require.NoError(t, err)
return plugin
}
func callHailuoHook(t *testing.T, plugin *jsplugin.LoadedPlugin, hook string, args ...any) map[string]any {
t.Helper()
value, err := plugin.Engine.Call(t.Context(), hook, args...)
require.NoError(t, err)
encoded, err := common.Marshal(value)
require.NoError(t, err)
var decoded map[string]any
require.NoError(t, common.Unmarshal(encoded, &decoded))
return decoded
}
func hailuoH3SubmitContext(requestBody map[string]any) map[string]any {
return map[string]any{
"requestBody": requestBody,
"model": "MiniMax-H3",
"upstreamModel": "MiniMax-H3",
"baseUrl": "https://api.minimax.example",
"apiKey": "test-ak",
}
}
// MiniMax-H3 submits to /v2/video_generation with a multimodal content array
// instead of the flat /v1 frame fields.
func TestHailuoH3BuildSubmitRequest(t *testing.T) {
plugin := loadHailuoPlugin(t)
testCases := []struct {
name string
request map[string]any
wantBody string
wantAction string
}{
{
name: "text to video defaults duration ratio and resolution",
request: map[string]any{"prompt": "a boy playing basketball"},
wantBody: `{"model":"MiniMax-H3","content":[{"type":"text","text":"a boy playing basketball"}],"resolution":"768P","duration":5,"ratio":"16:9"}`,
wantAction: "text_to_video",
},
{
name: "2K resolution from size",
request: map[string]any{"prompt": "p", "duration": 15, "size": "2K"},
wantBody: `{"model":"MiniMax-H3","content":[{"type":"text","text":"p"}],"resolution":"2K","duration":15,"ratio":"16:9"}`,
wantAction: "text_to_video",
},
{
name: "first and last frame from metadata",
request: map[string]any{"prompt": "p", "metadata": map[string]any{"first_frame_image": "first.png", "last_frame_image": "last.png"}},
wantBody: `{"model":"MiniMax-H3","content":[
{"type":"text","text":"p"},
{"type":"image_url","role":"first_frame","image_url":{"url":"first.png"}},
{"type":"image_url","role":"last_frame","image_url":{"url":"last.png"}}],
"resolution":"768P","duration":5,"ratio":"adaptive"}`,
wantAction: "image_to_video",
},
{
name: "frames from the images array",
request: map[string]any{"prompt": "p", "images": []any{"first.png", "last.png"}},
wantBody: `{"model":"MiniMax-H3","content":[
{"type":"text","text":"p"},
{"type":"image_url","role":"first_frame","image_url":{"url":"first.png"}},
{"type":"image_url","role":"last_frame","image_url":{"url":"last.png"}}],
"resolution":"768P","duration":5,"ratio":"adaptive"}`,
wantAction: "image_to_video",
},
{
name: "reference video and audio",
request: map[string]any{"prompt": "p", "metadata": map[string]any{
"reference_video": "ref.mp4",
"reference_audio": []any{"a.mp3", "b.mp3"},
}},
wantBody: `{"model":"MiniMax-H3","content":[
{"type":"text","text":"p"},
{"type":"video_url","role":"reference_video","video_url":{"url":"ref.mp4"}},
{"type":"audio_url","role":"reference_audio","audio_url":{"url":"a.mp3"}},
{"type":"audio_url","role":"reference_audio","audio_url":{"url":"b.mp3"}}],
"resolution":"768P","duration":5,"ratio":"adaptive"}`,
wantAction: "image_to_video",
},
{
name: "content passthrough prepends the prompt when no text item exists",
request: map[string]any{"prompt": "p", "metadata": map[string]any{"content": []any{
map[string]any{"type": "image_url", "role": "reference_image", "image_url": map[string]any{"url": "img.png"}},
}}},
wantBody: `{"model":"MiniMax-H3","content":[
{"type":"text","text":"p"},
{"type":"image_url","role":"reference_image","image_url":{"url":"img.png"}}],
"resolution":"768P","duration":5,"ratio":"adaptive"}`,
wantAction: "image_to_video",
},
{
name: "content passthrough keeps an existing text item",
request: map[string]any{"prompt": "ignored", "metadata": map[string]any{"content": []any{
map[string]any{"type": "text", "text": "kept"},
}}},
wantBody: `{"model":"MiniMax-H3","content":[{"type":"text","text":"kept"}],"resolution":"768P","duration":5,"ratio":"16:9"}`,
wantAction: "text_to_video",
},
{
name: "explicit ratio callback and watermark",
request: map[string]any{"prompt": "p", "duration": 4, "metadata": map[string]any{
"ratio": "9:16",
"callback_url": "https://example.com/cb",
"aigc_watermark": true,
}},
wantBody: `{"model":"MiniMax-H3","content":[{"type":"text","text":"p"}],"resolution":"768P","duration":4,"ratio":"9:16","callback_url":"https://example.com/cb","aigc_watermark":true}`,
wantAction: "text_to_video",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
descriptor := callHailuoHook(t, plugin, "buildSubmitRequest", hailuoH3SubmitContext(testCase.request))
assert.Equal(t, "https://api.minimax.example/v2/video_generation", descriptor["url"])
assert.Equal(t, "POST", descriptor["method"])
assert.Equal(t, testCase.wantAction, descriptor["action"])
body, err := common.Marshal(descriptor["body"])
require.NoError(t, err)
assert.JSONEq(t, testCase.wantBody, string(body))
})
}
}
// Every MiniMax-H3 request bound is rejected before the upstream call, so an
// out-of-range duration can never reach quota calculation as a billing fact.
func TestHailuoH3RejectsOutOfContractRequests(t *testing.T) {
plugin := loadHailuoPlugin(t)
tenReferenceImages := make([]any, 0, 10)
for range 10 {
tenReferenceImages = append(tenReferenceImages, map[string]any{
"type": "image_url", "role": "reference_image", "image_url": map[string]any{"url": "u"},
})
}
testCases := []struct {
name string
request map[string]any
wantErr string
}{
{"duration below the minimum", map[string]any{"prompt": "p", "duration": 3}, "duration must be an integer between 4 and 15"},
{"duration above the maximum", map[string]any{"prompt": "p", "duration": 16}, "duration must be an integer between 4 and 15"},
{"fractional duration", map[string]any{"prompt": "p", "duration": 5.5}, "duration must be an integer between 4 and 15"},
{"unsupported resolution", map[string]any{"prompt": "p", "size": "1080P"}, "resolution must be 768P or 2K"},
{"unknown ratio", map[string]any{"prompt": "p", "metadata": map[string]any{"ratio": "16:10"}}, "ratio must be one of"},
{"adaptive ratio without a visual input", map[string]any{"prompt": "p", "metadata": map[string]any{"ratio": "adaptive"}}, "ratio adaptive requires an image or video input"},
{"too many frame images", map[string]any{"prompt": "p", "images": []any{"a.png", "b.png", "c.png"}}, "at most 2 frame images"},
{"media without text", map[string]any{"images": []any{"a.png"}}, "requires a non-empty text item"},
{"content is not an array", map[string]any{"prompt": "p", "metadata": map[string]any{"content": "nope"}}, "metadata.content must be an array"},
{
name: "multiple first frame roles",
request: map[string]any{"prompt": "p", "metadata": map[string]any{"content": []any{
map[string]any{"type": "image_url", "role": "first_frame", "image_url": map[string]any{"url": "u1"}},
map[string]any{"type": "image_url", "role": "first_frame", "image_url": map[string]any{"url": "u2"}},
}}},
wantErr: "at most one first_frame image",
},
{
name: "passthrough mixes frame and reference media",
request: map[string]any{"prompt": "p", "metadata": map[string]any{"content": []any{
map[string]any{"type": "image_url", "role": "first_frame", "image_url": map[string]any{"url": "frame"}},
map[string]any{"type": "image_url", "role": "reference_image", "image_url": map[string]any{"url": "reference"}},
}}},
wantErr: "cannot mix frame images with reference media",
},
{
name: "assembled content mixes frame and reference media",
request: map[string]any{"prompt": "p", "images": []any{"frame"}, "metadata": map[string]any{
"reference_video": "reference.mp4",
}},
wantErr: "cannot mix frame images with reference media",
},
{
name: "too many reference videos",
request: map[string]any{"prompt": "p", "metadata": map[string]any{"content": []any{
map[string]any{"type": "video_url", "video_url": map[string]any{"url": "u1"}},
map[string]any{"type": "video_url", "video_url": map[string]any{"url": "u2"}},
map[string]any{"type": "video_url", "video_url": map[string]any{"url": "u3"}},
map[string]any{"type": "video_url", "video_url": map[string]any{"url": "u4"}},
}}},
wantErr: "at most 3 reference videos",
},
{
name: "too many reference images",
request: map[string]any{"prompt": "p", "metadata": map[string]any{"content": tenReferenceImages}},
wantErr: "at most 9 reference images",
},
{
name: "too many reference audios",
request: map[string]any{"prompt": "p", "metadata": map[string]any{"reference_audio": []any{"a.mp3", "b.mp3", "c.mp3", "d.mp3"}}},
wantErr: "at most 3 reference audios",
},
{"empty input", map[string]any{"prompt": " "}, "requires a prompt or a media input"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
_, err := plugin.Engine.Call(t.Context(), "buildSubmitRequest", hailuoH3SubmitContext(testCase.request))
require.ErrorContains(t, err, testCase.wantErr)
})
}
}
// The /v1 models keep the flat request shape and endpoint.
func TestHailuoLegacySubmitRequestUnchanged(t *testing.T) {
plugin := loadHailuoPlugin(t)
ctx := hailuoH3SubmitContext(map[string]any{"prompt": "p", "duration": 10, "size": "768P"})
ctx["model"] = "MiniMax-Hailuo-2.3"
ctx["upstreamModel"] = "MiniMax-Hailuo-2.3"
descriptor := callHailuoHook(t, plugin, "buildSubmitRequest", ctx)
assert.Equal(t, "https://api.minimax.example/v1/video_generation", descriptor["url"])
body, err := common.Marshal(descriptor["body"])
require.NoError(t, err)
assert.JSONEq(t, `{"model":"MiniMax-Hailuo-2.3","prompt":"p","duration":10,"resolution":"768P"}`, string(body))
}
func TestHailuoQueryRequestEndpointByModel(t *testing.T) {
plugin := loadHailuoPlugin(t)
testCases := []struct {
name string
ctx map[string]any
want string
}{
{
name: "H3 uses the v2 path parameter",
ctx: map[string]any{"taskId": "task/1", "upstreamModel": "MiniMax-H3"},
want: "https://api.minimax.example/v2/query/video_generation/task%2F1",
},
{
name: "an unmapped task falls back to the origin model",
ctx: map[string]any{"taskId": "t1", "model": "MiniMax-H3"},
want: "https://api.minimax.example/v2/query/video_generation/t1",
},
{
name: "legacy models keep the v1 query parameter",
ctx: map[string]any{"taskId": "t1", "upstreamModel": "MiniMax-Hailuo-2.3"},
want: "https://api.minimax.example/v1/query/video_generation?task_id=t1",
},
{
name: "a channel-mapped alias resolves through the upstream model",
ctx: map[string]any{"taskId": "t1", "model": "h3", "upstreamModel": "MiniMax-H3"},
want: "https://api.minimax.example/v2/query/video_generation/t1",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
testCase.ctx["baseUrl"] = "https://api.minimax.example"
testCase.ctx["apiKey"] = "test-ak"
descriptor := callHailuoHook(t, plugin, "buildQueryRequest", testCase.ctx)
assert.Equal(t, testCase.want, descriptor["url"])
assert.Equal(t, "GET", descriptor["method"])
})
}
}
func TestHailuoParseTaskResult(t *testing.T) {
plugin := loadHailuoPlugin(t)
testCases := []struct {
name string
body string
wantStatus string
wantURL string
wantReason string
}{
{"H3 queued", `{"task":{"id":"1","status":"queued"}}`, "QUEUED", "", ""},
{"H3 running", `{"task":{"id":"1","status":"running"}}`, "IN_PROGRESS", "", ""},
{"H3 succeeded", `{"task":{"id":"1","status":"succeeded","content":{"url":"https://cdn.example/h3.mp4"}}}`, "SUCCESS", "https://cdn.example/h3.mp4", ""},
{"H3 failed", `{"task":{"id":"1","status":"failed","error":{"code":"1026","message":"sensitive content"}}}`, "FAILURE", "", "sensitive content"},
{"H3 cancelled", `{"task":{"id":"1","status":"cancelled"}}`, "FAILURE", "", "task cancelled"},
{"H3 permanent query error", `{"type":"error","error":{"type":"authorized_error","message":"login failed","http_code":"401"}}`, "FAILURE", "", "login failed"},
{"legacy success", `{"task_id":"1","status":"Success","file_id":"f1","base_resp":{"status_code":0}}`, "SUCCESS", "", ""},
{"legacy processing", `{"task_id":"1","status":"Processing","base_resp":{"status_code":0}}`, "IN_PROGRESS", "", ""},
{"H3 unrecognized", `{"task":{"id":"1","status":"weird"}}`, "UNKNOWN", "", "unrecognized status: weird"},
{"legacy unrecognized", `{"task_id":"1","status":"Weird","base_resp":{"status_code":0}}`, "UNKNOWN", "", "unrecognized status: Weird"},
{"legacy base_resp failure", `{"task_id":"1","status":"Success","base_resp":{"status_code":1001,"status_msg":"upstream down"}}`, "FAILURE", "", "upstream down"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
var body any
require.NoError(t, common.UnmarshalJsonStr(testCase.body, &body))
result := callHailuoHook(t, plugin, "parseTaskResult", map[string]any{}, body)
assert.Equal(t, testCase.wantStatus, result["status"])
assert.Equal(t, testCase.wantURL, common.Interface2String(result["url"]))
assert.Equal(t, testCase.wantReason, common.Interface2String(result["reason"]))
})
}
t.Run("H3 retryable query error", func(t *testing.T) {
var body any
require.NoError(t, common.UnmarshalJsonStr(
`{"type":"error","error":{"type":"rate_limit_error","message":"retry later","http_code":"429"}}`, &body))
_, err := plugin.Engine.Call(t.Context(), "parseTaskResult", map[string]any{}, body)
require.ErrorContains(t, err, "retry later")
})
}
func TestHailuoExtractUsageFacts(t *testing.T) {
plugin := loadHailuoPlugin(t)
nineReferenceImages := make([]any, 0, 9)
for range 9 {
nineReferenceImages = append(nineReferenceImages, map[string]any{
"type": "image_url", "role": "reference_image", "image_url": map[string]any{"url": "image"},
})
}
testCases := []struct {
name string
model string
request map[string]any
want map[string]any
}{
{"H3 defaults", "MiniMax-H3", map[string]any{"prompt": "p"}, map[string]any{
"seconds": float64(5), "resolution": "768P", "input_images": float64(0), "input_video_seconds": float64(0),
}},
{"H3 2K", "MiniMax-H3", map[string]any{"prompt": "p", "duration": 12, "size": "2K"}, map[string]any{
"seconds": float64(12), "resolution": "2K", "input_images": float64(0), "input_video_seconds": float64(0),
}},
{"H3 reference images", "MiniMax-H3", map[string]any{"prompt": "p", "metadata": map[string]any{"content": nineReferenceImages}}, map[string]any{
"seconds": float64(5), "resolution": "768P", "input_images": float64(9), "input_video_seconds": float64(0),
}},
{"H3 reference video reserves total duration limit", "MiniMax-H3", map[string]any{"prompt": "p", "metadata": map[string]any{
"reference_video": []any{"one.mp4", "two.mp4", "three.mp4"},
}}, map[string]any{
"seconds": float64(5), "resolution": "768P", "input_images": float64(0), "input_video_seconds": float64(15),
}},
{"legacy model", "MiniMax-Hailuo-2.3", map[string]any{"prompt": "p", "duration": 10}, map[string]any{
"seconds": float64(10), "resolution": "768P", "input_images": float64(0), "input_video_seconds": float64(0),
}},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
ctx := hailuoH3SubmitContext(testCase.request)
ctx["model"] = testCase.model
ctx["upstreamModel"] = testCase.model
assert.Equal(t, testCase.want, callHailuoHook(t, plugin, "extractUsage", ctx))
})
}
}
func TestHailuoH3CompletionUsageFacts(t *testing.T) {
plugin := loadHailuoPlugin(t)
testCases := []struct {
name string
body string
want map[string]any
}{
{
name: "actual usage replaces submission estimates",
body: `{"task":{"id":"1","status":"succeeded","resolution":"2K","usage":{"output_seconds":5,"input_seconds":7.5,"input_image_count":6}}}`,
want: map[string]any{"seconds": float64(5), "resolution": "2K", "input_images": float64(6), "input_video_seconds": float64(7.5)},
},
{
name: "zero actual usage is retained for settlement",
body: `{"task":{"id":"1","status":"succeeded","resolution":"768P","usage":{"output_seconds":4,"input_seconds":0,"input_image_count":0}}}`,
want: map[string]any{"seconds": float64(4), "resolution": "768P", "input_images": float64(0), "input_video_seconds": float64(0)},
},
{
name: "zero output cannot erase the submission reservation",
body: `{"task":{"id":"1","status":"succeeded","resolution":"768P","usage":{"output_seconds":0,"input_seconds":0,"input_image_count":0}}}`,
want: map[string]any{"resolution": "768P", "input_images": float64(0), "input_video_seconds": float64(0)},
},
{
name: "missing usage leaves submission estimates untouched",
body: `{"task":{"id":"1","status":"succeeded","resolution":"768P"}}`,
want: map[string]any{"resolution": "768P"},
},
{
name: "out of contract usage cannot become a billing multiplier",
body: `{"task":{"id":"1","status":"succeeded","resolution":"2K","usage":{"output_seconds":16,"input_seconds":16,"input_image_count":10}}}`,
want: map[string]any{"resolution": "2K"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
var body any
require.NoError(t, common.UnmarshalJsonStr(testCase.body, &body))
assert.Equal(t, testCase.want, callHailuoHook(t, plugin, "extractUsageOnComplete", nil, nil, body))
})
}
t.Run("polling adaptor carries actual facts into task settlement", func(t *testing.T) {
adaptor := taskplugin.New(plugin)
result, err := adaptor.ParseTaskResult(&model.Task{}, &http.Response{StatusCode: http.StatusOK, Header: make(http.Header)}, []byte(
`{"task":{"id":"1","status":"succeeded","resolution":"2K","usage":{"output_seconds":5,"input_seconds":7.5,"input_image_count":6}}}`,
))
require.NoError(t, err)
assert.Equal(t, map[string]any{
"seconds": float64(5), "resolution": "2K", "input_images": float64(6), "input_video_seconds": float64(7.5),
}, result.UsageFacts)
})
}
// The /v2 result is a public CDN URL, so its artifact is proxied without
// channel credentials instead of through the /v1 file download endpoint.
func TestHailuoH3ArtifactContentProxy(t *testing.T) {
source, err := builtinplugins.Source("hailuo")
require.NoError(t, err)
plugin, err := jsplugin.NewRegistry().RegisterFactory(source, jsplugin.Options{Key: "hailuo"})
require.NoError(t, err)
adaptor := taskplugin.New(plugin)
adaptor.Init(&relaycommon.RelayInfo{
ChannelMeta: &relaycommon.ChannelMeta{ApiKey: "test-ak", ChannelBaseUrl: "https://api.minimax.example"},
})
task := &model.Task{
TaskID: "task-public",
Status: model.TaskStatusSuccess,
Data: []byte(`{"task":{"id":"1","status":"succeeded","content":{"url":"https://cdn.example/h3.mp4"}}}`),
}
artifacts, err := adaptor.ListArtifacts(task)
require.NoError(t, err)
assert.Equal(t, []channel.TaskArtifact{{Key: "video", Type: "video", MimeType: "video/mp4"}}, artifacts)
descriptor, err := adaptor.BuildContentRequest(task, "video", channel.TaskArtifactClientRequest{Method: http.MethodGet})
require.NoError(t, err)
require.NotNil(t, descriptor)
assert.Equal(t, "https://cdn.example/h3.mp4", descriptor.URL)
assert.True(t, descriptor.Credentialless)
pending := &model.Task{TaskID: "task-public", Status: model.TaskStatusInProgress, Data: task.Data}
pendingArtifacts, err := adaptor.ListArtifacts(pending)
require.NoError(t, err)
assert.Empty(t, pendingArtifacts)
}
// The /v2 create response carries only task_id, without the /v1 base_resp envelope.
func TestHailuoParseSubmitResponse(t *testing.T) {
plugin := loadHailuoPlugin(t)
t.Run("H3 create has no envelope", func(t *testing.T) {
parsed := callHailuoHook(t, plugin, "parseSubmitResponse", map[string]any{"upstreamModel": "MiniMax-H3"},
map[string]any{"body": map[string]any{"task_id": "h3-1"}})
assert.Equal(t, "h3-1", parsed["taskId"])
})
t.Run("H3 rejection reports the envelope message", func(t *testing.T) {
_, err := plugin.Engine.Call(t.Context(), "parseSubmitResponse", map[string]any{"upstreamModel": "MiniMax-H3"},
map[string]any{"body": map[string]any{"base_resp": map[string]any{"status_code": 2013, "status_msg": "invalid params"}}})
require.ErrorContains(t, err, "invalid params")
})
t.Run("H3 rejection reports the v2 error message", func(t *testing.T) {
_, err := plugin.Engine.Call(t.Context(), "parseSubmitResponse", map[string]any{"upstreamModel": "MiniMax-H3"},
map[string]any{"body": map[string]any{"type": "error", "error": map[string]any{
"type": "bad_request_error", "message": "content requires text", "http_code": "400",
}}})
require.ErrorContains(t, err, "content requires text")
})
t.Run("legacy create", func(t *testing.T) {
parsed := callHailuoHook(t, plugin, "parseSubmitResponse", map[string]any{"upstreamModel": "MiniMax-Hailuo-2.3"},
map[string]any{"body": map[string]any{"task_id": "v1-1", "base_resp": map[string]any{"status_code": 0}}})
assert.Equal(t, "v1-1", parsed["taskId"])
})
t.Run("legacy response without an envelope is rejected", func(t *testing.T) {
ctx := map[string]any{"upstreamModel": "MiniMax-Hailuo-2.3"}
_, err := plugin.Engine.Call(t.Context(), "parseSubmitResponse", ctx,
map[string]any{"body": map[string]any{"task_id": "v1-1"}})
require.ErrorContains(t, err, "hailuo submit failed")
})
t.Run("legacy error", func(t *testing.T) {
_, err := plugin.Engine.Call(t.Context(), "parseSubmitResponse", map[string]any{"upstreamModel": "MiniMax-Hailuo-2.3"},
map[string]any{"body": map[string]any{"base_resp": map[string]any{"status_code": 1026, "status_msg": "sensitive"}}})
require.ErrorContains(t, err, "sensitive")
})
}
// Without an H3 exemption the shared /v1 combo table would reject every H3
// duration on the OpenAI Video path before the request is ever built.
func TestHailuoH3PassesOpenAIVideoDecode(t *testing.T) {
plugin := loadHailuoPlugin(t)
value, err := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_video", "decodeRequest"},
map[string]any{
"body": map[string]any{"kind": "json", "value": map[string]any{"model": "MiniMax-H3", "prompt": "p", "seconds": 12, "size": "2K"}},
"model": "MiniMax-H3",
"upstreamModel": "MiniMax-H3",
})
require.NoError(t, err)
encoded, err := common.Marshal(value)
require.NoError(t, err)
var intent map[string]any
require.NoError(t, common.Unmarshal(encoded, &intent))
assert.Equal(t, "submit", intent["kind"])
requestBody, ok := intent["requestBody"].(map[string]any)
require.True(t, ok)
assert.Equal(t, float64(12), requestBody["duration"])
}
+92
View File
@@ -0,0 +1,92 @@
package plugins_test
import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/pkg/jsplugin"
builtinplugins "github.com/QuantumNous/new-api/plugins"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestJimengResponsesProtocol(t *testing.T) {
testVideoResponsesProtocol(t, videoResponsesTestCase{
pluginKey: "jimeng",
model: "jimeng_vgfm_t2v_l20",
requestBody: map[string]any{
"model": "jimeng_vgfm_t2v_l20",
"input": "a paper boat on a river",
"seconds": 10,
"metadata": map[string]any{
"aspect_ratio": "16:9",
},
},
wantAction: "text_to_video",
wantRequest: map[string]any{
"model": "jimeng_vgfm_t2v_l20",
"prompt": "a paper boat on a river",
"duration": float64(10),
"metadata": map[string]any{"aspect_ratio": "16:9"},
},
wantUsageKeys: []string{"product", "seconds"},
wantVendorName: "jimeng",
})
}
func loadJimengPlugin(t *testing.T) *jsplugin.LoadedPlugin {
t.Helper()
source, err := builtinplugins.Source("jimeng")
require.NoError(t, err)
plugin, err := jsplugin.NewRegistry().RegisterFactory(source, jsplugin.Options{Key: "jimeng"})
require.NoError(t, err)
return plugin
}
func TestJimengSubmitStateDrivesQueryReqKey(t *testing.T) {
plugin := loadJimengPlugin(t)
submitValue, err := plugin.Engine.Call(t.Context(), "parseSubmitResponse", map[string]any{
"upstreamModel": "jimeng_vgfm_i2v_l20",
"requestBody": map[string]any{"images": []any{"https://cdn.example/frame.png"}},
}, map[string]any{"body": map[string]any{"code": 10000, "data": map[string]any{"task_id": "t1"}}})
require.NoError(t, err)
encoded, err := common.Marshal(submitValue)
require.NoError(t, err)
var submit map[string]any
require.NoError(t, common.Unmarshal(encoded, &submit))
state, ok := submit["state"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "jimeng_vgfm_i2v_l20", state["req_key"])
queryValue, err := plugin.Engine.Call(t.Context(), "buildQueryRequest", map[string]any{
"taskId": "t1",
"action": "text_to_video",
"baseUrl": "https://jimeng.example",
"apiKey": "sk-test",
"state": map[string]any{"req_key": "custom_req_key"},
})
require.NoError(t, err)
queryEncoded, err := common.Marshal(queryValue)
require.NoError(t, err)
var query map[string]any
require.NoError(t, common.Unmarshal(queryEncoded, &query))
var body map[string]any
require.NoError(t, common.UnmarshalJsonStr(common.Interface2String(query["body"]), &body))
assert.Equal(t, "custom_req_key", body["req_key"])
assert.Equal(t, "t1", body["task_id"])
}
func TestJimengParseTaskResultUnknownStatus(t *testing.T) {
plugin := loadJimengPlugin(t)
value, err := plugin.Engine.Call(t.Context(), "parseTaskResult", map[string]any{}, map[string]any{
"code": 10000,
"data": map[string]any{"status": "weird"},
})
require.NoError(t, err)
encoded, err := common.Marshal(value)
require.NoError(t, err)
var result map[string]any
require.NoError(t, common.Unmarshal(encoded, &result))
assert.Equal(t, "UNKNOWN", result["status"])
assert.Contains(t, common.Interface2String(result["reason"]), "weird")
}
+31
View File
@@ -0,0 +1,31 @@
package plugins_test
import "testing"
func TestKlingResponsesProtocol(t *testing.T) {
testVideoResponsesProtocol(t, videoResponsesTestCase{
pluginKey: "kling",
model: "kling-v2-master",
requestBody: map[string]any{
"model": "kling-v2-master",
"input": []any{map[string]any{"role": "user", "content": []any{
map[string]any{"type": "input_text", "text": "camera orbit"},
map[string]any{"type": "input_image", "image_url": "https://cdn.example/frame.png"},
}}},
"seconds": 10,
"metadata": map[string]any{
"mode": "pro",
},
},
wantAction: "image_to_video",
wantRequest: map[string]any{
"model": "kling-v2-master",
"prompt": "camera orbit",
"image": "https://cdn.example/frame.png",
"duration": float64(10),
"metadata": map[string]any{"mode": "pro"},
},
wantUsageKeys: []string{"units"},
wantVendorName: "kling",
})
}
+25
View File
@@ -0,0 +1,25 @@
package plugins_test
import "testing"
func TestSoraResponsesProtocol(t *testing.T) {
testVideoResponsesProtocol(t, videoResponsesTestCase{
pluginKey: "sora",
model: "sora-2-pro",
requestBody: map[string]any{
"model": "sora-2-pro",
"input": "waves at sunset",
"seconds": 8,
"size": "1792x1024",
},
wantAction: "text_to_video",
wantRequest: map[string]any{
"model": "sora-2-pro",
"prompt": "waves at sunset",
"seconds": float64(8),
"size": "1792x1024",
},
wantUsageKeys: []string{"seconds", "size"},
wantVendorName: "sora",
})
}
+247
View File
@@ -0,0 +1,247 @@
package plugins_test
import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/pkg/jsplugin"
builtinplugins "github.com/QuantumNous/new-api/plugins"
"github.com/QuantumNous/new-api/relay"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSunoResponsesProtocol(t *testing.T) {
source, err := builtinplugins.Source("sunoapi")
require.NoError(t, err)
registry := jsplugin.NewRegistry()
plugin, err := registry.RegisterFactory(source, jsplugin.Options{Key: "sunoapi"})
require.NoError(t, err)
t.Run("claims both Suno models", 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("declares documented usage facts", func(t *testing.T) {
require.Len(t, plugin.Meta.UsageSchema, 2)
for _, key := range []string{"clips", "action"} {
schema, exists := plugin.Meta.UsageSchema[key]
require.True(t, exists, key)
assert.NotEmpty(t, schema.Description, key)
}
})
callProtocol := func(t *testing.T, hook string, args ...any) any {
t.Helper()
value, callErr := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", hook}, args...)
require.NoError(t, callErr)
return value
}
decodeMap := func(t *testing.T, value any) map[string]any {
t.Helper()
encoded, marshalErr := common.Marshal(value)
require.NoError(t, marshalErr)
var decoded map[string]any
require.NoError(t, common.Unmarshal(encoded, &decoded))
return decoded
}
t.Run("parses music and lyrics requests", func(t *testing.T) {
music := decodeMap(t, callProtocol(t, "decodeRequest", map[string]any{"model": "suno_music", "body": map[string]any{"kind": "json", "value": map[string]any{
"model": "suno_music", "input": "summer pop", "metadata": map[string]any{"title": "Sunset"},
}}}))
assert.Equal(t, "suno_music", music["model"])
assert.Equal(t, "MUSIC", music["action"])
assert.Equal(t, map[string]any{"gpt_description_prompt": "summer pop", "title": "Sunset"}, music["requestBody"])
lyrics := decodeMap(t, callProtocol(t, "decodeRequest", map[string]any{"model": "suno_lyrics", "body": map[string]any{"kind": "json", "value": map[string]any{
"model": "suno_lyrics", "input": "write about the sea",
}}}))
assert.Equal(t, "suno_lyrics", lyrics["model"])
assert.Equal(t, "LYRICS", lyrics["action"])
assert.Equal(t, map[string]any{"prompt": "write about the sea"}, lyrics["requestBody"])
})
t.Run("rejects malformed input", func(t *testing.T) {
_, callErr := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "decodeRequest"}, map[string]any{
"model": "suno_music", "body": map[string]any{"kind": "json", "value": map[string]any{"model": "suno_music", "input": map[string]any{"text": "bad"}}},
})
require.ErrorContains(t, callErr, "input must be a string or array")
})
t.Run("echoes a channel-mapped alias", func(t *testing.T) {
music := decodeMap(t, callProtocol(t, "decodeRequest", map[string]any{
"model": "my-suno", "upstreamModel": "suno_music", "stream": false,
"body": map[string]any{"kind": "json", "value": map[string]any{"model": "my-suno", "input": "summer pop"}},
}))
assert.Equal(t, "my-suno", music["model"])
assert.Equal(t, "MUSIC", music["action"])
lyrics := decodeMap(t, callProtocol(t, "decodeRequest", map[string]any{
"model": "my-suno", "upstreamModel": "suno_lyrics", "stream": false,
"body": map[string]any{"kind": "json", "value": map[string]any{"model": "my-suno", "input": "write about the sea"}},
}))
assert.Equal(t, "my-suno", lyrics["model"])
assert.Equal(t, "LYRICS", lyrics["action"])
assert.Equal(t, map[string]any{"prompt": "write about the sea"}, lyrics["requestBody"])
})
t.Run("rejects a model this plugin does not serve", func(t *testing.T) {
_, callErr := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "decodeRequest"}, map[string]any{
"model": "gpt-4o", "body": map[string]any{"kind": "json", "value": map[string]any{"model": "gpt-4o", "input": "hello"}},
})
require.ErrorContains(t, callErr, "suno_music or suno_lyrics")
})
t.Run("extracts schema-declared usage", func(t *testing.T) {
value, callErr := plugin.Engine.Call(t.Context(), "extractUsage", map[string]any{
"model": "suno_music", "action": "MUSIC", "usagePurpose": "facts", "requestBody": map[string]any{},
})
require.NoError(t, callErr)
assert.Equal(t, map[string]any{"clips": int64(2), "action": "music"}, value)
value, callErr = plugin.Engine.Call(t.Context(), "extractUsageOnComplete", nil, map[string]any{}, []any{
map[string]any{"id": "song-1", "audio_url": "https://upstream.example/one.mp3"},
map[string]any{"id": "song-2", "audio_url": "https://upstream.example/two.mp3"},
})
require.NoError(t, callErr)
assert.Equal(t, map[string]any{"clips": int64(2), "action": "music"}, value)
})
const firstAudioKey = "audio-5fc0a0cd3367274b4b6de056fc754263f8726a704bb4814ffeb88495f22dad35"
const secondAudioKey = "audio-a9c04b840373f4ef4e8d80140b745c6f647819fa375bc34368cdccced7e2b455"
protocolContext := map[string]any{
"model": "suno_music",
"stream": true,
"artifacts": map[string]any{
firstAudioKey: map[string]any{"key": firstAudioKey, "type": "audio", "url": "https://gateway.example/artifacts/song-1"},
secondAudioKey: map[string]any{"key": secondAudioKey, "type": "audio", "url": "https://gateway.example/artifacts/song-2"},
},
}
successTask := map[string]any{
"task_id": "task-public", "status": "SUCCESS", "progress": "100%", "created_at": 10, "updated_at": 20,
"data": []any{
map[string]any{"id": "song-1", "title": "First", "text": "First lyrics", "audio_url": "https://upstream.example/one.mp3"},
map[string]any{"id": "song-2", "title": "Second", "text": "Second lyrics", "audio_url": "https://upstream.example/two.mp3"},
},
}
t.Run("renders stream state transitions", func(t *testing.T) {
progressValue := callProtocol(t, "renderEvents", protocolContext, map[string]any{"status": "IN_PROGRESS", "progress": "40%"})
progress, decodeErr := relay.DecodePluginProtocolEventResult(progressValue, relay.DefaultPluginProtocolLimits())
require.NoError(t, decodeErr)
require.Len(t, progress.Events, 1)
require.NotNil(t, progress.Events[0].Progress)
assert.Equal(t, float64(40), *progress.Events[0].Progress)
duplicateValue := callProtocol(t, "renderEvents", protocolContext, map[string]any{"status": "IN_PROGRESS", "progress": "40%"}, map[string]any{"status": "IN_PROGRESS", "progress": float64(40)})
duplicate, decodeErr := relay.DecodePluginProtocolEventResult(duplicateValue, relay.DefaultPluginProtocolLimits())
require.NoError(t, decodeErr)
assert.Empty(t, duplicate.Events)
failureValue := callProtocol(t, "renderEvents", protocolContext, map[string]any{"status": "FAILURE", "fail_reason": "blocked"})
failure, decodeErr := relay.DecodePluginProtocolEventResult(failureValue, relay.DefaultPluginProtocolLimits())
require.NoError(t, decodeErr)
require.Len(t, failure.Events, 1)
assert.Equal(t, "error", failure.Events[0].Type)
assert.True(t, failure.Done)
successValue := callProtocol(t, "renderEvents", protocolContext, successTask)
success, decodeErr := relay.DecodePluginProtocolEventResult(successValue, relay.DefaultPluginProtocolLimits())
require.NoError(t, decodeErr)
require.Len(t, success.Events, 1)
var text string
require.NoError(t, common.Unmarshal(success.Events[0].Data, &text))
assert.Contains(t, text, "First lyrics")
assert.Contains(t, text, "gateway.example/artifacts/song-1")
assert.Contains(t, text, "gateway.example/artifacts/song-2")
assert.NotContains(t, text, "upstream.example")
assert.True(t, success.Done)
})
t.Run("renders one music message with lyrics and audio segments", func(t *testing.T) {
value := callProtocol(t, "renderFinal", protocolContext, successTask)
machine := relay.NewPluginResponsesMachine("task-public", "suno_music", 10, relay.DefaultPluginProtocolLimits())
response, finalErr := machine.FinalResponse(value, "SUCCESS")
require.NoError(t, finalErr)
output, ok := response["output"].([]any)
require.True(t, ok)
require.Len(t, output, 1)
message, ok := output[0].(map[string]any)
require.True(t, ok)
content, ok := message["content"].([]any)
require.True(t, ok)
require.Len(t, content, 3)
for index, expected := range []string{"First lyrics", "gateway.example/artifacts/song-1", "gateway.example/artifacts/song-2"} {
part, partOK := content[index].(map[string]any)
require.True(t, partOK)
text, textOK := part["text"].(string)
require.True(t, textOK)
assert.Contains(t, text, expected)
assert.NotContains(t, text, "upstream.example")
}
metadata, ok := response["metadata"].(map[string]string)
require.True(t, ok)
assert.Equal(t, "sunoapi", metadata["vendor"])
})
t.Run("renders lyrics without audio artifacts", func(t *testing.T) {
value := callProtocol(t, "renderFinal", map[string]any{"model": "suno_lyrics"}, map[string]any{
"task_id": "lyrics-public", "status": "SUCCESS", "data": map[string]any{"id": "lyrics-1", "title": "Tide", "text": "Sea lyrics"},
})
machine := relay.NewPluginResponsesMachine("lyrics-public", "suno_lyrics", 10, relay.DefaultPluginProtocolLimits())
response, finalErr := machine.FinalResponse(value, "SUCCESS")
require.NoError(t, finalErr)
output, ok := response["output"].([]any)
require.True(t, ok)
message, ok := output[0].(map[string]any)
require.True(t, ok)
content, ok := message["content"].([]any)
require.True(t, ok)
require.Len(t, content, 1)
part, ok := content[0].(map[string]any)
require.True(t, ok)
assert.Contains(t, part["text"], "Sea lyrics")
})
t.Run("requires host audio artifacts", func(t *testing.T) {
_, callErr := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "renderFinal"}, map[string]any{
"model": "suno_music",
}, successTask)
require.ErrorContains(t, callErr, "audio artifact is unavailable")
assert.NotContains(t, callErr.Error(), "upstream.example")
})
t.Run("renders lyrics for an alias on retrieval without upstreamModel", func(t *testing.T) {
lyricsTask := map[string]any{
"task_id": "lyrics-alias", "status": "SUCCESS", "data": map[string]any{"id": "lyrics-1", "title": "Tide", "text": "Sea lyrics"},
}
finalValue := callProtocol(t, "renderFinal", map[string]any{"model": "my-lyrics"}, lyricsTask)
machine := relay.NewPluginResponsesMachine("lyrics-alias", "my-lyrics", 10, relay.DefaultPluginProtocolLimits())
response, finalErr := machine.FinalResponse(finalValue, "SUCCESS")
require.NoError(t, finalErr)
output, ok := response["output"].([]any)
require.True(t, ok)
message, ok := output[0].(map[string]any)
require.True(t, ok)
content, ok := message["content"].([]any)
require.True(t, ok)
require.Len(t, content, 1)
part, ok := content[0].(map[string]any)
require.True(t, ok)
assert.Contains(t, part["text"], "Sea lyrics")
eventsValue := callProtocol(t, "renderEvents", map[string]any{"model": "my-lyrics"}, lyricsTask)
events, decodeErr := relay.DecodePluginProtocolEventResult(eventsValue, relay.DefaultPluginProtocolLimits())
require.NoError(t, decodeErr)
require.Len(t, events.Events, 1)
var text string
require.NoError(t, common.Unmarshal(events.Events[0].Data, &text))
assert.Contains(t, text, "Sea lyrics")
})
}
+505
View File
@@ -0,0 +1,505 @@
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: "阿里云百炼万相视频生成(文生视频、图生视频)",
},
version: "1.1.1",
author: { name: "QuantumNous" },
channelTypes: [17],
models: [
"wan3.0-video",
"wan3.0-video-prime",
"wan2.7-i2v",
"wan2.7-t2v",
"wan2.5-t2v-preview",
"wan2.5-i2v-preview",
"wan2.2-i2v-flash",
"wan2.2-i2v-plus",
"wanx2.1-i2v-plus",
"wanx2.1-i2v-turbo",
],
fetchMode: "per_task",
usageSchema: {
// Requested video duration in seconds.
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: "输出视频分辨率" },
},
},
routes: [
{ method: "POST", path: "/ali/api/v1/services/aigc/video-generation/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"],
};
function trimmed(value) {
return String(value || "").trim();
}
function firstImage(req) {
if (trimmed(req.image)) return trimmed(req.image);
for (const image of req.images || []) if (trimmed(image)) return trimmed(image);
return trimmed(req.input_reference);
}
function secondImage(req) {
let count = 0;
for (const image of req.images || []) {
if (!trimmed(image)) continue;
count++;
if (count === 2) return trimmed(image);
}
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",
};
// 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;
for (const key of ["duration", "seconds"]) {
if (Number(req[key]) === -1) {
auto = true;
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;
return req;
}
function normalizeResolution(value) {
let resolution = String(value || "").toUpperCase();
if (!resolution.endsWith("P")) resolution += "P";
return resolution;
}
function convert(ctx) {
const req = ctx.requestBody;
const upstreamModel = ctx.upstreamModel || req.model;
const input = { prompt: req.prompt || "" };
const image = firstImage(req);
if (image) input.img_url = image;
const parameters = { prompt_extend: true, duration: 5 };
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";
} else {
parameters.resolution = "720P";
}
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 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) {
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 (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");
}
delete input.img_url;
delete input.first_frame_url;
delete input.last_frame_url;
delete input.audio_url;
}
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;
}
function resolutionRatio(body) {
let resolution = body.parameters.size ? SIZE_TO_RESOLUTION[body.parameters.size] : normalizeResolution(body.parameters.resolution);
const ratios = {
"wan3.0-video": { "480P": 1, "720P": 2, "1080P": 4 },
"wan3.0-video-prime": { "480P": 1, "720P": 2, "1080P": 4 },
"wan2.6-i2v": { "720P": 1, "1080P": 1 / 0.6 },
"wan2.5-t2v-preview": { "480P": 1, "720P": 2, "1080P": 1 / 0.3 },
"wan2.2-t2v-plus": { "480P": 1, "1080P": 5 },
"wan2.5-i2v-preview": { "480P": 1, "720P": 2, "1080P": 1 / 0.3 },
"wan2.2-i2v-plus": { "480P": 1, "1080P": 5 },
"wan2.2-kf2v-flash": { "480P": 1, "720P": 2, "1080P": 4.8 },
"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;
}
function responsesInput(req) {
const texts = [],
images = [];
const input = req.input;
if (typeof input === "string") texts.push(input);
else if (Array.isArray(input)) {
for (const item of input) {
if (typeof item === "string") {
texts.push(item);
continue;
}
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const content = item.content === undefined ? [item] : Array.isArray(item.content) ? item.content : [item.content];
for (const part of content) {
if (typeof part === "string") {
texts.push(part);
continue;
}
if (!part || typeof part !== "object" || Array.isArray(part)) continue;
if (["input_text", "text"].includes(part.type) && typeof part.text === "string") texts.push(part.text);
if (["input_image", "image_url"].includes(part.type)) {
let image = part.image_url;
if (image && typeof image === "object") image = image.url;
if (trimmed(image)) images.push(trimmed(image));
}
}
}
}
return {
prompt: texts
.filter(function (text) {
return trimmed(text);
})
.join("\n"),
images: images,
};
}
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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
return '<video controls src="' + escaped + '"></video>';
}
export function buildSubmitRequest(ctx) {
const body = convert(ctx);
return {
url: ctx.baseUrl + "/api/v1/services/aigc/video-generation/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",
};
}
export function parseSubmitResponse(ctx, resp) {
const body = resp.body || {};
if (body.code) throw new Error(body.code + ": " + (body.message || ""));
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) {
const body = convert(ctx);
const duration = Number(body.parameters.duration);
const seconds = Math.min(duration === -1 ? 30 : duration, 3600);
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";
return { seconds: seconds, resolution: resolution };
}
export function extractUsageOnComplete(task, taskResult, 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;
return facts;
}
export function buildQueryRequest(ctx) {
return { url: ctx.baseUrl + "/api/v1/tasks/" + ctx.taskId, method: "GET", headers: { Authorization: "Bearer " + ctx.apiKey } };
}
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 (["FAILED", "CANCELED", "UNKNOWN"].includes(output.task_status)) {
let reason = body.message || "";
if (!reason && output.message) reason = "task failed, code: " + (output.code || "") + " , message: " + output.message;
if (!reason) reason = "task failed";
return { status: "FAILURE", reason: reason };
}
return { status: "UNKNOWN", reason: "unrecognized status: " + String(output.task_status || "") };
}
function artifactData(ctx) {
const data = (ctx && ctx.data) || {};
if (data.data && typeof data.data === "object" && data.data.task_id && Object.prototype.hasOwnProperty.call(data.data, "data")) return data.data.data || {};
return data;
}
export function listArtifacts(task) {
const output = artifactData(task).output || {};
return task.status === "SUCCESS" && trimmed(output.video_url) ? [{ 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);
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 || {};
const requestBody = {
model: req.model,
prompt: input.prompt || "",
image: input.img_url,
duration: parameters.duration,
size: parameters.size || parameters.resolution,
};
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,
action: input.img_url || hasMedia ? "image_to_video" : "text_to_video",
requestBody: requestBody,
};
},
taskCreated: function (ctx, task) {
const data = task.data || {};
return { request_id: data.request_id || "", output: { task_id: task.task_id, task_status: "PENDING" } };
},
taskStatus: function (ctx, task) {
const data = task.data || {},
output = Object.assign({}, data.output || {}, { task_id: task.task_id });
return Object.assign({}, data, { output: output });
},
error: function (ctx, error) {
return { code: error.code, message: error.message, request_id: "" };
},
};
export const protocols = {
openai_responses: {
decodeRequest: function (ctx) {
if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
const req = ctx.body.value;
if (!req || typeof req !== "object" || Array.isArray(req)) throw new Error("request body must be an object");
const model = trimmed(ctx.model);
if (!model) throw new Error("model is required");
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 };
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 = [];
for (const image of req.images || []) if (trimmed(image) && !images.includes(trimmed(image))) images.push(trimmed(image));
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"]) {
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 };
},
renderEvents: function (ctx, task, previousState) {
const status = String(task.status || "UNKNOWN").toUpperCase();
const value = Number(String(task.progress || "").replace("%", ""));
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 events = previousState && previousState.status === status ? [] : text ? [{ type: "output", data: text }] : [];
return { events: events, state: state, done: true };
}
if (status === "FAILURE") {
return { events: [{ type: "error", code: "task_failed", message: "task failed" }], state: state, done: true };
}
if (previousState && previousState.status === status && previousState.progress === progress) {
return { events: [], state: state, done: false };
}
const event = { type: "progress", message: status.toLowerCase() };
if (progress !== null) event.progress = progress;
return { events: [event], state: state, done: false };
},
renderFinal: function (ctx, _task) {
return {
output: [
{
type: "message",
status: "completed",
role: "assistant",
content: [
{
type: "output_text",
text: responsesVideoText(ctx),
annotations: [],
logprobs: [],
},
],
},
],
metadata: { vendor: "ali" },
};
},
},
openai_video: {
decodeRequest: function (ctx) {
let req;
if (ctx.body && ctx.body.kind === "json") req = ctx.body.value;
else if (ctx.body && ctx.body.kind === "multipart") {
if ((ctx.body.files || []).length) throw new Error("Alibaba requires image references to be URLs");
const first = function (name) {
const values = (ctx.body.fields || {})[name] || [];
if (values.length > 1) throw new Error(name + " must be provided once");
return values[0];
};
req = {};
const fields = ctx.body.fields || {};
for (const name of Object.keys(fields)) {
if (name === "images") req.images = fields[name] || [];
else req[name] = first(name);
}
if (req.metadata !== undefined) {
let parsed;
try {
parsed = JSON.parse(req.metadata);
} catch (e) {
throw new Error("metadata must be a JSON object string");
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("metadata must be a JSON object string");
req.metadata = parsed;
}
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);
} else throw new Error("JSON or multipart body required");
const requestBody = normalizeAutoDuration(Object.assign({}, req, { model: ctx.model }));
return {
kind: "submit",
model: ctx.model,
action: firstImage(requestBody) ? "image_to_video" : "text_to_video",
requestBody: requestBody,
};
},
render: function (ctx, task) {
const data = task.data || {},
outputData = data.output || {};
const statuses = {
PENDING: "queued",
RUNNING: "in_progress",
SUCCEEDED: "completed",
FAILED: "failed",
CANCELED: "failed",
UNKNOWN: "failed",
};
const output = {
id: task.task_id,
object: "video",
model: task.properties ? task.properties.origin_model_name || "" : "",
status: statuses[outputData.task_status] || "unknown",
progress: Number(String(task.progress || "0").replace("%", "")),
created_at: task.created_at,
completed_at: task.updated_at,
};
if (data.code) output.error = { code: data.code, message: data.message || "" };
else if (outputData.code) output.error = { code: outputData.code, message: outputData.message || "" };
return output;
},
},
};
+502
View File
@@ -0,0 +1,502 @@
export const meta = {
apiVersion: 1,
key: "doubao",
name: "Doubao Video",
icon: "Doubao.Color",
description: {
en: "Volcengine Doubao Seedance video generation (text-to-video, image-to-video, and video-to-video)",
zh: "火山引擎豆包 Seedance 视频生成(文生视频、图生视频、视频生视频)",
},
version: "1.0.2",
author: { name: "QuantumNous" },
channelTypes: [54, 45], // VolcEngine-type channels serve Ark video models with the same wire format
models: [
"doubao-seedance-1-0-pro-250528",
"doubao-seedance-1-0-lite-t2v",
"doubao-seedance-1-0-lite-i2v",
"doubao-seedance-1-5-pro-251215",
"doubao-seedance-2-0-260128",
"doubao-seedance-2-0-fast-260128",
"doubao-seedance-2-0-mini-260615",
"doubao-seedance-2-5-260628",
],
fetchMode: "per_task",
usageSchema: {
// Upstream billing tokens (estimated at submit, actual on completion).
tokens: {
type: "number",
unit: "token",
description: { en: "Billing token unit price", zh: "计费 Token 单价" },
},
// Output video resolution; Seedance token unit price varies by resolution tier.
resolution: {
enum: ["480p", "720p", "1080p", "4k"],
enumLabels: {
"480p": { en: "480p", zh: "480p" },
"720p": { en: "720p", zh: "720p" },
"1080p": { en: "1080p", zh: "1080p" },
"4k": { en: "4k", zh: "4k" },
},
description: { en: "Output video resolution", zh: "输出视频分辨率" },
},
// Whether the request includes reference video input; Seedance prices video-to-video tokens at a lower unit rate.
video_input: {
enum: ["none", "video"],
enumLabels: { none: { en: "No reference video", zh: "无参考视频" }, video: { en: "With reference video", zh: "有参考视频" } },
description: { en: "Reference video input", zh: "参考视频输入" },
},
},
// Official Ark formula tokens = (input + output seconds) × W × H × 24 / 1024,
// 16:9 max-pixel sizes, cross-checked against Volcengine price examples.
usageExamples: [
{ label: "480p · 5s", facts: { tokens: 48038, resolution: "480p", video_input: "none" } },
{ label: "720p · 5s", facts: { tokens: 108000, resolution: "720p", video_input: "none" } },
{ label: "1080p · 5s", facts: { tokens: 243000, resolution: "1080p", video_input: "none" } },
{ label: "4k · 5s", facts: { tokens: 972000, resolution: "4k", video_input: "none" } },
{ label: "720p · 10s", facts: { tokens: 216000, resolution: "720p", video_input: "none" } },
{ label: "720p · 5s (+4s 输入视频)", facts: { tokens: 194400, resolution: "720p", video_input: "video" } },
],
routes: [
{ method: "POST", path: "/doubao/api/v3/contents/generations/tasks", type: "submit", decode: "createTask", render: "taskCreated" },
{ method: "GET", path: "/doubao/api/v3/contents/generations/tasks/:task_id", type: "query", render: "taskStatus" },
],
protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
};
function trimmed(value) {
return String(value || "").trim();
}
function draftTaskIds(content) {
const ids = [];
if (!Array.isArray(content)) return ids;
for (const item of content) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
if (item.type !== "draft_task") continue;
const draft = item.draft_task;
if (!draft || typeof draft !== "object" || Array.isArray(draft)) continue;
const id = trimmed(draft.id);
if (id) ids.push(id);
}
return ids;
}
function rewriteDraftTaskContent(content, originTasks) {
if (!Array.isArray(content)) return content;
return content.map(function (item) {
if (!item || typeof item !== "object" || Array.isArray(item) || item.type !== "draft_task") return item;
const draft = item.draft_task;
if (!draft || typeof draft !== "object" || Array.isArray(draft) || !trimmed(draft.id)) return item;
const publicId = trimmed(draft.id);
let upstream = "";
if (Array.isArray(originTasks)) {
for (const task of originTasks) {
if (task && task.taskId === publicId) {
upstream = trimmed(task.upstreamTaskId);
break;
}
}
}
if (!upstream) throw new Error("origin task is unavailable");
return Object.assign({}, item, { draft_task: Object.assign({}, draft, { id: upstream }) });
});
}
function normalizeResolution(value) {
const raw = trimmed(value).toLowerCase();
if (["480p", "720p", "1080p", "4k"].includes(raw)) return raw;
const parts = raw.replace("*", "x").split("x");
if (parts.length !== 2) return "720p";
const max = Math.max(Number(parts[0]), Number(parts[1]));
if (max >= 3840) return "4k";
if (max >= 1920) return "1080p";
if (max >= 1280) return "720p";
return "480p";
}
function hasVideo(content) {
return Array.isArray(content) && content.some((item) => item && (item.type === "video_url" || Object.prototype.hasOwnProperty.call(item, "video_url")));
}
// Max-pixel 16:9 dimensions per resolution tier. Used when ratio is absent or
// adaptive so the submit-time estimate overestimates rather than underestimates.
// Official Ark formula: tokens = seconds × width × height × 24 / 1024.
// Video input duration is omitted; extractUsageOnComplete overlays the real bill.
function resolutionMaxPixels(resolution) {
if (resolution === "480p") return [854, 480];
if (resolution === "1080p") return [1920, 1080];
if (resolution === "4k") return [3840, 2160];
return [1280, 720];
}
function estimateTokens(seconds, resolution) {
const dims = resolutionMaxPixels(resolution);
return (seconds * dims[0] * dims[1] * 24) / 1024;
}
function videoInputRatio(model, resolution, content) {
const video = hasVideo(content);
const res = trimmed(resolution).toLowerCase();
if (model === "doubao-seedance-2-5-260628") {
if (res === "1080p") return video ? 7.0 / 10.7 : 11.7 / 10.7;
return video ? 42 / 70 : 1;
}
if (model === "doubao-seedance-2-0-260128") {
if (res === "1080p") return video ? 31 / 46 : 51 / 46;
if (res === "4k") return video ? 16 / 46 : 26 / 46;
return video ? 28 / 46 : 1;
}
if (model === "doubao-seedance-2-0-fast-260128") return video ? 22 / 37 : 1;
if (model === "doubao-seedance-2-0-mini-260615") return video ? 14 / 23 : 1;
return 1;
}
function responsesInput(req) {
const texts = [],
images = [];
const input = req.input;
if (typeof input === "string") texts.push(input);
else if (Array.isArray(input)) {
for (const item of input) {
if (typeof item === "string") {
texts.push(item);
continue;
}
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const content = item.content === undefined ? [item] : Array.isArray(item.content) ? item.content : [item.content];
for (const part of content) {
if (typeof part === "string") {
texts.push(part);
continue;
}
if (!part || typeof part !== "object" || Array.isArray(part)) continue;
if (["input_text", "text"].includes(part.type) && typeof part.text === "string") texts.push(part.text);
if (["input_image", "image_url"].includes(part.type)) {
let image = part.image_url;
if (image && typeof image === "object") image = image.url;
if (trimmed(image)) images.push(trimmed(image));
}
}
}
}
return {
prompt: texts
.filter(function (text) {
return trimmed(text);
})
.join("\n"),
images: images,
};
}
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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
return '<video controls src="' + escaped + '"></video>';
}
export const native = {
createTask: function (ctx) {
if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
const body = ctx.body.value;
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("request body must be an object");
const model = trimmed(body.model);
if (!model) throw new Error("model is required");
if (body.content !== undefined && !Array.isArray(body.content)) throw new Error("content must be an array");
const content = Array.isArray(body.content) ? body.content : [];
const texts = [];
let hasReference = false;
for (const item of content) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
if (item.type === "text" && typeof item.text === "string") texts.push(item.text);
else hasReference = true;
}
if (!texts.length && !hasReference) throw new Error("content is required");
const requestBody = {
model: model,
prompt: texts
.filter(function (text) {
return trimmed(text);
})
.join("\n"),
metadata: body,
};
const seconds = Number(body.duration);
if (Number.isFinite(seconds) && seconds > 0) requestBody.seconds = seconds;
const intent = { kind: "submit", model: model, action: hasReference ? "image_to_video" : "text_to_video", requestBody: requestBody };
const originTaskIds = draftTaskIds(content);
if (originTaskIds.length) intent.originTaskIds = originTaskIds;
return intent;
},
taskCreated: function (ctx, task) {
const data = task.data && typeof task.data === "object" && !Array.isArray(task.data) ? task.data : {};
return Object.assign({}, data, { id: task.task_id });
},
taskStatus: function (ctx, task) {
if (task.data && typeof task.data === "object" && !Array.isArray(task.data)) return Object.assign({}, task.data, { id: task.task_id });
const statusMap = { NOT_START: "queued", SUBMITTED: "queued", QUEUED: "queued", IN_PROGRESS: "running", SUCCESS: "succeeded", FAILURE: "failed" };
const output = { id: task.task_id, status: statusMap[task.status] || "queued" };
if (task.fail_reason) output.error = { message: task.fail_reason };
return output;
},
error: function (ctx, error) {
return { error: { code: error.code, message: error.message } };
},
};
export function buildSubmitRequest(ctx) {
const req = ctx.requestBody;
const metadata = req.metadata || {};
const body = Object.assign({ model: req.model || "", content: [] }, metadata);
const imageContent = [];
const images = Array.isArray(req.images) ? req.images : [];
for (const url of images) imageContent.push({ type: "image_url", image_url: { url: url } });
const metadataContent = Array.isArray(body.content) ? body.content : [];
body.content = imageContent.concat(metadataContent).filter((item) => item && item.type !== "text");
const hasReference = body.content.length > 0;
if (trimmed(req.prompt) || !hasReference) body.content.push({ type: "text", text: req.prompt || "" });
if (Array.isArray(body.content)) body.content = rewriteDraftTaskContent(body.content, ctx.originTasks);
const seconds = Number.parseInt(req.seconds || "", 10);
if (seconds > 0) body.duration = seconds;
body.model = ctx.upstreamModel || body.model;
return {
url: ctx.baseUrl + "/api/v3/contents/generations/tasks",
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: "Bearer " + ctx.apiKey },
body: body,
action: hasReference ? "image_to_video" : "text_to_video",
rewriteModel: body.model,
};
}
export function parseSubmitResponse(ctx, resp) {
if (!resp.body || !resp.body.id) throw new Error("task_id is empty");
return { taskId: resp.body.id, taskData: resp.body };
}
export function extractUsage(ctx) {
const req = ctx.requestBody || {};
const metadata = req.metadata || {};
if (ctx.usagePurpose === "billing_ratios") {
const ratio = videoInputRatio(ctx.upstreamModel || ctx.model, metadata.resolution, metadata.content);
return ratio === 1 ? null : { video_input_ratio: ratio };
}
let seconds = Number(req.seconds || req.duration || metadata.duration || 0);
if (!Number.isFinite(seconds) || seconds <= 0) {
const frames = Number(metadata.frames);
seconds = Number.isFinite(frames) && frames > 0 ? Math.floor(frames / 24) : 15;
}
if (seconds <= 0) seconds = 5;
seconds = Math.min(seconds, 3600);
const rawResolution = metadata.resolution || req.size;
const raw = trimmed(rawResolution).toLowerCase();
const recognized = ["480p", "720p", "1080p", "4k"].includes(raw) || raw.replace("*", "x").split("x").length === 2;
const resolution = recognized ? normalizeResolution(rawResolution) : "1080p";
return {
tokens: estimateTokens(seconds, resolution),
resolution: resolution,
video_input: hasVideo(metadata.content) ? "video" : "none",
};
}
export function buildQueryRequest(ctx) {
return {
url: ctx.baseUrl + "/api/v3/contents/generations/tasks/" + ctx.taskId,
method: "GET",
headers: { Accept: "application/json", "Content-Type": "application/json", Authorization: "Bearer " + ctx.apiKey },
};
}
export function parseTaskResult(ctx, body) {
if (body.status === "pending" || body.status === "queued") return { status: "QUEUED", progress: "10%" };
if (body.status === "processing" || body.status === "running") return { status: "IN_PROGRESS", progress: "50%" };
if (body.status === "succeeded") {
const result = { status: "SUCCESS", progress: "100%", url: body.content && body.content.video_url ? body.content.video_url : "" };
const usage = body.usage || {};
const completionTokens = Number(usage.completion_tokens || 0);
const totalTokens = Number(usage.total_tokens || 0);
if (Number.isFinite(completionTokens) && completionTokens > 0) result.completionTokens = completionTokens;
if (Number.isFinite(totalTokens) && totalTokens > 0) result.totalTokens = totalTokens;
return result;
}
if (body.status === "failed" || body.status === "expired" || body.status === "cancelled") {
const reason = body.error && body.error.message ? body.error.message : body.status;
return { status: "FAILURE", progress: "100%", reason: reason };
}
return { status: "UNKNOWN", reason: "unrecognized status: " + String(body.status || "") };
}
function artifactData(ctx) {
const data = (ctx && ctx.data) || {};
if (data.data && typeof data.data === "object" && data.data.task_id && Object.prototype.hasOwnProperty.call(data.data, "data")) return data.data.data || {};
return data;
}
export function listArtifacts(task) {
if (task.status !== "SUCCESS") return [];
const content = artifactData(task).content || {};
const artifacts = [];
if (trimmed(content.video_url)) artifacts.push({ key: "video", type: "video" });
if (trimmed(content.last_frame_url)) artifacts.push({ key: "last_frame", type: "image", mimeType: "image/png" });
return artifacts;
}
export function buildContentRequest(ctx) {
const content = artifactData(ctx).content || {};
const urls = { video: content.video_url, last_frame: content.last_frame_url };
const url = trimmed(urls[ctx.artifactKey]);
if (!url) throw new Error("artifact_not_found");
return { url: url, method: ctx.clientRequest.method, credentialless: true };
}
export function extractUsageOnComplete(task, taskResult, body) {
if (!body || body.status !== "succeeded") return {};
const facts = {};
const usage = body.usage || {};
let tokens = Number(usage.completion_tokens);
if (!Number.isFinite(tokens) || tokens <= 0) tokens = Number(usage.total_tokens);
if (Number.isFinite(tokens) && tokens > 0) facts.tokens = tokens;
const content = body.content || {};
const resolution = trimmed(content.resolution || body.resolution).toLowerCase();
if (["480p", "720p", "1080p", "4k"].includes(resolution)) facts.resolution = resolution;
return facts;
}
export const protocols = {
openai_responses: {
decodeRequest: function (ctx) {
if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
const req = ctx.body.value;
if (!req || typeof req !== "object" || Array.isArray(req)) throw new Error("request body must be an object");
const model = trimmed(req.model);
if (!model) throw new Error("model is required");
if (req.input !== undefined && typeof req.input !== "string" && !Array.isArray(req.input)) throw new Error("input must be a string or array");
if (req.images !== undefined && !Array.isArray(req.images)) throw new Error("images must be an array");
if (req.metadata !== undefined && (!req.metadata || typeof req.metadata !== "object" || Array.isArray(req.metadata)))
throw new Error("metadata must be an object");
const input = responsesInput(req);
const prompt = input.prompt || trimmed(req.prompt);
const images = [];
for (const image of [req.image, req.input_reference].concat(req.images || [], input.images)) {
if (trimmed(image) && !images.includes(trimmed(image))) images.push(trimmed(image));
}
if (!prompt && images.length === 0) throw new Error("input is required");
const metadata = Object.assign({}, req.metadata || {});
if (Object.prototype.hasOwnProperty.call(req, "resolution")) metadata.resolution = req.resolution;
else if (req.size && !metadata.resolution) metadata.resolution = normalizeResolution(req.size);
const requestBody = { model: model, prompt: prompt, metadata: metadata };
if (images.length) requestBody.images = images;
if (Object.prototype.hasOwnProperty.call(req, "seconds")) requestBody.seconds = req.seconds;
else if (Object.prototype.hasOwnProperty.call(req, "duration")) requestBody.seconds = req.duration;
if (Object.prototype.hasOwnProperty.call(req, "size")) requestBody.size = req.size;
const intent = { kind: "submit", model: model, action: images.length ? "image_to_video" : "text_to_video", requestBody: requestBody };
const originTaskIds = draftTaskIds(metadata.content);
if (originTaskIds.length) intent.originTaskIds = originTaskIds;
return intent;
},
renderEvents: function (ctx, task, previousState) {
const status = String(task.status || "UNKNOWN").toUpperCase();
const value = Number(String(task.progress || "").replace("%", ""));
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 events = previousState && previousState.status === status ? [] : [{ type: "output", data: text }];
return { events: events, state: state, done: true };
}
if (status === "FAILURE")
return { events: [{ type: "error", code: "task_failed", message: task.fail_reason || "task failed" }], state: state, done: true };
if (previousState && previousState.status === status && previousState.progress === progress) return { events: [], state: state, done: false };
const event = { type: "progress", message: status.toLowerCase() };
if (progress !== null) event.progress = progress;
return { events: [event], state: state, done: false };
},
renderFinal: function (ctx, _task) {
return {
output: [
{
type: "message",
status: "completed",
role: "assistant",
content: [{ type: "output_text", text: responsesVideoText(ctx), annotations: [], logprobs: [] }],
},
],
metadata: { vendor: "doubao" },
};
},
},
};
const legacyRenderers = {
openai_video: function (task) {
const data = task.data || {};
const statusMap = { NOT_START: "queued", SUBMITTED: "queued", QUEUED: "queued", IN_PROGRESS: "in_progress", SUCCESS: "completed", FAILURE: "failed" };
const output = {
id: task.task_id,
object: "video",
model: task.properties ? task.properties.origin_model_name || "" : "",
status: statusMap[task.status] || "unknown",
progress: Number(String(task.progress || "0").replace("%", "")),
created_at: task.created_at,
completed_at: task.updated_at,
};
if (data.status === "failed") output.error = { message: data.error ? data.error.message || "" : "", code: data.error ? data.error.code || "" : "" };
return output;
},
};
protocols.openai_video = {
decodeRequest: function (ctx) {
if (!ctx.body || (ctx.body.kind !== "json" && ctx.body.kind !== "multipart")) throw new Error("JSON or multipart body required");
if (ctx.body.kind === "json") {
if (!ctx.body.value || Array.isArray(ctx.body.value)) throw new Error("JSON object required");
const req = ctx.body.value;
const seconds = req.seconds === undefined ? req.duration : req.seconds;
if (seconds !== undefined && (!Number.isFinite(Number(seconds)) || Number(seconds) <= 0 || Number(seconds) > 3600))
throw new Error("seconds must be between 1 and 3600");
return {
kind: "submit",
model: ctx.model,
action: req.input_reference || req.image ? "image_to_video" : "text_to_video",
requestBody: Object.assign({}, req, { model: ctx.model }),
};
}
const first = function (name) {
const values = (ctx.body.fields || {})[name] || [];
if (values.length > 1) throw new Error(name + " must be provided once");
return values[0];
};
const req = {};
const fields = ctx.body.fields || {};
for (const name of Object.keys(fields)) {
req[name] = first(name);
}
if (req.metadata !== undefined) {
let parsed;
try {
parsed = JSON.parse(req.metadata);
} catch (e) {
throw new Error("metadata must be a JSON object string");
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("metadata must be a JSON object string");
req.metadata = parsed;
}
if ((ctx.body.files || []).length) throw new Error("Doubao requires image and video references to be URLs inside metadata.content");
if (req.seconds !== undefined) req.seconds = Number(req.seconds);
else if (req.duration !== undefined) req.seconds = Number(req.duration);
const seconds = req.seconds === undefined ? req.duration : req.seconds;
if (seconds !== undefined && (!Number.isFinite(Number(seconds)) || Number(seconds) <= 0 || Number(seconds) > 3600))
throw new Error("seconds must be between 1 and 3600");
return {
kind: "submit",
model: ctx.model,
action: req.input_reference || req.image ? "image_to_video" : "text_to_video",
requestBody: Object.assign({}, req, { model: ctx.model }),
};
},
render: function (ctx, task) {
return legacyRenderers.openai_video(task);
},
};
+382
View File
@@ -0,0 +1,382 @@
export const meta = {
apiVersion: 1,
key: "google",
name: "Google Veo (Gemini API)",
icon: "Gemini.Color",
description: {
en: "Google Veo video generation on the Gemini API (text-to-video and image-to-video)",
zh: "Google Veo 视频生成(文生视频、图生视频),Gemini API 版本",
},
version: "1.0.2",
author: { name: "QuantumNous" },
channelTypes: [24],
models: ["veo-3.0-generate-001", "veo-3.0-fast-generate-001", "veo-3.1-generate-preview", "veo-3.1-fast-generate-preview"],
fetchMode: "per_task",
usageSchema: {
// Requested video duration in seconds. Allowed values: 4, 6, 8.
seconds: {
type: "number",
unit: "second",
description: { en: "Video generation unit price", zh: "视频生成单价" },
},
// Requested video output resolution. Veo prices differ per resolution tier.
resolution: {
enum: ["720p", "1080p", "4k"],
enumLabels: { "720p": { en: "720p", zh: "720p" }, "1080p": { en: "1080p", zh: "1080p" }, "4k": { en: "4k", zh: "4k" } },
description: { en: "Output video resolution", zh: "输出视频分辨率" },
},
},
usageExamples: [
{ label: "8s × 720p", facts: { seconds: 8, resolution: "720p" } },
{ label: "8s × 1080p", facts: { seconds: 8, resolution: "1080p" } },
{ label: "8s × 4k", facts: { seconds: 8, resolution: "4k" } },
{ label: "4s × 720p", facts: { seconds: 4, resolution: "720p" } },
],
protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
};
function trimmed(value) {
return String(value || "").trim();
}
function responsesInput(req) {
const texts = [],
images = [];
const input = req.input;
if (typeof input === "string") texts.push(input);
else if (Array.isArray(input)) {
for (const item of input) {
if (typeof item === "string") {
texts.push(item);
continue;
}
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const content = item.content === undefined ? [item] : Array.isArray(item.content) ? item.content : [item.content];
for (const part of content) {
if (typeof part === "string") {
texts.push(part);
continue;
}
if (!part || typeof part !== "object" || Array.isArray(part)) continue;
if (["input_text", "text"].includes(part.type) && typeof part.text === "string") texts.push(part.text);
if (["input_image", "image_url"].includes(part.type)) {
let image = part.image_url;
if (image && typeof image === "object") image = image.url;
if (trimmed(image)) images.push(trimmed(image));
}
}
}
}
return {
prompt: texts
.filter(function (text) {
return trimmed(text);
})
.join("\n"),
images: images,
};
}
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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
return '<video controls src="' + escaped + '"></video>';
}
function sizeParts(size) {
const parts = String(size || "")
.toLowerCase()
.split("x");
if (parts.length !== 2) return null;
return [Number(parts[0]), Number(parts[1])];
}
function resolutionForSize(size) {
const parts = sizeParts(size);
if (!parts) return "720p";
const max = Math.max(parts[0], parts[1]);
if (max >= 3840) return "4k";
if (max >= 1920) return "1080p";
return "720p";
}
function aspectForSize(size) {
const parts = sizeParts(size);
if (!parts || parts[0] <= 0 || parts[1] <= 0) return "16:9";
return parts[1] > parts[0] ? "9:16" : "16:9";
}
function imageInput(value, files) {
if (value && typeof value === "object" && !Array.isArray(value) && value.__fileRef) {
let mime = value.mimeType || "";
if (!mime && files) {
for (const file of files) {
if (file.ref === value.__fileRef || file.field === "input_reference") {
mime = file.mimeType || "";
break;
}
}
}
return { inlineData: { mimeType: mime || "application/octet-stream", data: value } };
}
value = String(value || "").trim();
if (!value) return null;
if (value.startsWith("data:")) {
const comma = value.indexOf(",");
if (comma < 0 || !value.slice(comma + 1)) return null;
const mediaType = value.slice(5, comma).split(";")[0];
return { inlineData: { mimeType: mediaType || "application/octet-stream", data: value.slice(comma + 1) } };
}
// Raw base64 input is accepted by the Go adaptor. The common fixtures use
// PNG data; browser-free plugins cannot invoke net/http DetectContentType.
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
let mime = "application/octet-stream";
if (value.startsWith("iVBORw0KGgo")) mime = "image/png";
else if (value.startsWith("/9j/")) mime = "image/jpeg";
else if (value.startsWith("R0lGOD")) mime = "image/gif";
else if (value.startsWith("UklGR")) mime = "image/webp";
return { inlineData: { mimeType: mime, data: value } };
}
function converted(ctx) {
const req = ctx.requestBody || {};
const metadata = Object.assign({}, req.metadata || {});
const params = Object.assign({}, metadata);
if (Number(req.duration) > 0) params.durationSeconds = Number(req.duration);
if (!params.resolution && req.size) params.resolution = resolutionForSize(req.size);
if (!params.aspectRatio && req.size) params.aspectRatio = aspectForSize(req.size);
if (params.resolution) params.resolution = String(params.resolution).toLowerCase();
params.numberOfVideos = 1;
const instance = { prompt: req.prompt };
const image = imageInput((req.images || [])[0], ctx.files);
if (image) instance.image = image;
return { body: { instances: [instance], parameters: params }, action: image ? "image_to_video" : "text_to_video" };
}
function version(ctx) {
const settings = ctx.userSetting || {};
return settings.geminiVersion || "v1beta";
}
export function buildSubmitRequest(ctx) {
const result = converted(ctx);
return {
url: ctx.baseUrl + "/" + version(ctx) + "/models/" + ctx.upstreamModel + ":predictLongRunning",
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json", "x-goog-api-key": ctx.apiKey },
body: result.body,
action: result.action,
};
}
export function parseSubmitResponse(ctx, resp) {
const body = resp.body || {};
if (!String(body.name || "").trim()) throw new Error("missing operation name");
const result = { taskId: utils.base64URL(body.name), taskData: body };
if (body.done && !(body.error && body.error.message)) {
const videos = ((body.response || {}).generateVideoResponse || {}).generatedVideos || [];
const uri = videos.length && videos[0].video ? videos[0].video.uri || "" : "";
result.immediate = { taskId: result.taskId, status: "SUCCESS", progress: "100%", remoteUrl: uri };
}
return result;
}
export function extractUsage(ctx) {
const req = ctx.requestBody || {};
const metadata = req.metadata || {};
let seconds = Number(req.duration);
if (!Number.isFinite(seconds) || seconds <= 0) seconds = 8;
const resolution = String(metadata.resolution || resolutionForSize(req.size) || "720p").toLowerCase();
return { seconds: seconds, resolution: resolution };
}
export function extractUsageOnComplete() {
return null;
}
export function buildQueryRequest(ctx) {
return {
url: ctx.baseUrl + "/v1beta/" + utils.base64URLDecode(ctx.taskId),
method: "GET",
headers: { Accept: "application/json", "x-goog-api-key": ctx.apiKey },
};
}
export function parseTaskResult(ctx, body) {
if (body.error && body.error.message) return { status: "FAILURE", progress: "100%", reason: body.error.message };
// Google long-running operations omit `done` (proto3 default) while still
// running, so a missing key means in-progress; only a non-operation shape is
// unrecognized.
if (!body || typeof body !== "object" || !String(body.name || "").trim()) return { status: "UNKNOWN", reason: "unrecognized operation state" };
if (body.done !== true) return { status: "IN_PROGRESS", progress: "50%" };
const videos = ((body.response || {}).generateVideoResponse || {}).generatedVideos || [];
const uri = videos.length && videos[0].video ? videos[0].video.uri || "" : "";
return { taskId: utils.base64URL(body.name || ""), status: "SUCCESS", progress: "100%", remoteUrl: uri };
}
function artifactData(ctx) {
const data = (ctx && ctx.data) || {};
if (data.data && typeof data.data === "object" && data.data.task_id && Object.prototype.hasOwnProperty.call(data.data, "data")) return data.data.data || {};
return data;
}
function artifactVideoURL(ctx) {
const videos = ((artifactData(ctx).response || {}).generateVideoResponse || {}).generatedVideos || [];
return videos.length && videos[0].video ? String(videos[0].video.uri || "").trim() : "";
}
export function listArtifacts(task) {
return task.status === "SUCCESS" && artifactVideoURL(task) ? [{ key: "video", type: "video" }] : [];
}
export function buildContentRequest(ctx) {
if (ctx.artifactKey !== "video") throw new Error("artifact_not_found");
const url = artifactVideoURL(ctx);
if (!url) throw new Error("artifact_not_found");
return { url: url, method: ctx.clientRequest.method, headers: { "x-goog-api-key": ctx.apiKey } };
}
export const protocols = {
openai_responses: {
decodeRequest: function (ctx) {
if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
const req = ctx.body.value;
if (!req || typeof req !== "object" || Array.isArray(req)) throw new Error("request body must be an object");
const model = trimmed(req.model);
if (!model) throw new Error("model is required");
if (req.input !== undefined && typeof req.input !== "string" && !Array.isArray(req.input)) throw new Error("input must be a string or array");
if (req.images !== undefined && !Array.isArray(req.images)) throw new Error("images must be an array");
if (req.metadata !== undefined && (!req.metadata || typeof req.metadata !== "object" || Array.isArray(req.metadata)))
throw new Error("metadata must be an object");
const input = responsesInput(req);
const prompt = input.prompt || trimmed(req.prompt);
const images = [];
for (const image of [req.image, req.input_reference].concat(req.images || [], input.images)) {
if (trimmed(image) && !images.includes(trimmed(image))) images.push(trimmed(image));
}
if (!prompt && images.length === 0) throw new Error("input is required");
if (images.length && !imageInput(images[0])) throw new Error("input image must be a data URL or base64 value");
const metadata = Object.assign({}, req.metadata || {});
if (Object.prototype.hasOwnProperty.call(req, "resolution")) metadata.resolution = req.resolution;
const requestBody = { model: model, prompt: prompt, metadata: metadata };
if (images.length) requestBody.images = images;
if (Object.prototype.hasOwnProperty.call(req, "seconds")) requestBody.duration = req.seconds;
else if (Object.prototype.hasOwnProperty.call(req, "duration")) requestBody.duration = req.duration;
if (Object.prototype.hasOwnProperty.call(req, "size")) requestBody.size = req.size;
return { kind: "submit", model: model, action: images.length ? "image_to_video" : "text_to_video", requestBody: requestBody };
},
renderEvents: function (ctx, task, previousState) {
const status = String(task.status || "UNKNOWN").toUpperCase();
const value = Number(String(task.progress || "").replace("%", ""));
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 events = previousState && previousState.status === status ? [] : [{ type: "output", data: text }];
return { events: events, state: state, done: true };
}
if (status === "FAILURE")
return { events: [{ type: "error", code: "task_failed", message: task.fail_reason || "task failed" }], state: state, done: true };
if (previousState && previousState.status === status && previousState.progress === progress) return { events: [], state: state, done: false };
const event = { type: "progress", message: status.toLowerCase() };
if (progress !== null) event.progress = progress;
return { events: [event], state: state, done: false };
},
renderFinal: function (ctx, _task) {
return {
output: [
{
type: "message",
status: "completed",
role: "assistant",
content: [{ type: "output_text", text: responsesVideoText(ctx), annotations: [], logprobs: [] }],
},
],
metadata: { vendor: "gemini" },
};
},
},
};
const legacyRenderers = {
openai_video: function (task) {
const model = (task.properties || {}).origin_model_name || "veo-3.0-generate-001";
const statuses = { SUBMITTED: "queued", QUEUED: "queued", IN_PROGRESS: "in_progress", SUCCESS: "completed", FAILURE: "failed" };
const output = {
id: task.task_id,
object: "video",
model: model,
status: statuses[task.status] || "unknown",
progress: Number(String(task.progress || "0").replace("%", "")),
created_at: task.created_at,
};
if (Number(task.finish_time) > 0) output.completed_at = Number(task.finish_time);
else if (Number(task.updated_at) > 0) output.completed_at = Number(task.updated_at);
return output;
},
};
protocols.openai_video = {
decodeRequest: function (ctx) {
if (!ctx.body || (ctx.body.kind !== "json" && ctx.body.kind !== "multipart")) throw new Error("JSON or multipart body required");
let req;
let hasInputReferenceFile = false;
if (ctx.body.kind === "json") {
if (!ctx.body.value || Array.isArray(ctx.body.value)) throw new Error("JSON object required");
req = Object.assign({}, ctx.body.value);
} else {
const first = function (name) {
const values = (ctx.body.fields || {})[name] || [];
if (values.length > 1) throw new Error(name + " must be provided once");
return values[0];
};
req = {};
const fields = ctx.body.fields || {};
for (const name of Object.keys(fields)) {
req[name] = first(name);
}
for (const file of ctx.body.files || []) {
if (file.field !== "input_reference") throw new Error("unexpected file field: " + file.field);
if (hasInputReferenceFile) throw new Error("input_reference must be provided once");
hasInputReferenceFile = true;
}
if (req.metadata !== undefined) {
let parsed;
try {
parsed = JSON.parse(req.metadata);
} catch {
throw new Error("metadata must be a JSON object string");
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("metadata must be a JSON object string");
req.metadata = parsed;
}
if (req.seconds !== undefined) req.seconds = Number(req.seconds);
else if (req.duration !== undefined) req.seconds = Number(req.duration);
}
const seconds = req.seconds === undefined ? req.duration : req.seconds;
if (seconds !== undefined) {
const n = Number(seconds);
if (n !== 4 && n !== 6 && n !== 8) throw new Error("seconds must be one of 4, 6, or 8");
req.duration = n;
}
const providedResolution = req.resolution !== undefined ? req.resolution : req.metadata && req.metadata.resolution;
if (providedResolution !== undefined && providedResolution !== "") {
const resolution = String(providedResolution).toLowerCase();
if (resolution !== "720p" && resolution !== "1080p" && resolution !== "4k") throw new Error("resolution must be one of 720p, 1080p, or 4k");
}
if (hasInputReferenceFile) {
req.images = [{ __fileRef: "request_file:input_reference", encoding: "base64", maxBytes: 20971520 }];
}
return {
kind: "submit",
model: ctx.model,
action: hasInputReferenceFile || req.input_reference || req.image ? "image_to_video" : "text_to_video",
requestBody: Object.assign({}, req, { model: ctx.model }),
};
},
render: function (ctx, task) {
return legacyRenderers.openai_video(task);
},
};
+716
View File
@@ -0,0 +1,716 @@
export const meta = {
apiVersion: 1,
key: "hailuo",
name: "Hailuo Video",
icon: "Hailuo.Color",
description: {
en: "MiniMax Hailuo video generation (text-to-video, image-to-video, and MiniMax-H3 multimodal reference)",
zh: "MiniMax 海螺视频生成(文生视频、图生视频、MiniMax-H3 多模态参考生视频)",
},
version: "1.1.3",
author: { name: "QuantumNous" },
channelTypes: [35],
models: [
"MiniMax-H3",
"MiniMax-Hailuo-2.3",
"MiniMax-Hailuo-2.3-Fast",
"MiniMax-Hailuo-02",
"T2V-01-Director",
"T2V-01",
"I2V-01-Director",
"I2V-01-live",
"I2V-01",
"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.
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: "输出视频分辨率" },
},
// 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 } },
{ label: "2.3/02 1080P 6s", facts: { seconds: 6, resolution: "1080P", input_images: 0, input_video_seconds: 0 } },
{ label: "02 512P 6s", facts: { seconds: 6, resolution: "512P", input_images: 0, input_video_seconds: 0 } },
{ label: "02 512P 10s", facts: { seconds: 10, resolution: "512P", input_images: 0, input_video_seconds: 0 } },
{ label: "01-series 720P 6s", facts: { seconds: 6, resolution: "720P", input_images: 0, input_video_seconds: 0 } },
{ label: "H3 768P 5s", facts: { seconds: 5, resolution: "768P", 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 } },
],
protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
};
function trimmed(value) {
return String(value || "").trim();
}
function isModernHailuo(model) {
return model === "MiniMax-Hailuo-2.3" || model === "MiniMax-Hailuo-2.3-Fast" || model === "MiniMax-Hailuo-02";
}
function defaultResolution(model) {
if (model === "MiniMax-Hailuo-2.3" || model === "MiniMax-Hailuo-2.3-Fast" || model === "MiniMax-Hailuo-02") return "768P";
return "720P";
}
function resolutionFor(size, model) {
const value = String(size || "");
if (value.includes("1080")) return "1080P";
if (value.includes("768")) return "768P";
if (value.includes("720")) return isModernHailuo(model) ? "768P" : "720P";
if (value.includes("512")) return "512P";
return defaultResolution(model);
}
function outboundDuration(req) {
const n = Number(req && req.duration);
if (Number.isFinite(n) && n > 0) return n;
return 6;
}
function outboundResolution(req, model) {
if (req && req.resolution) return resolutionFor(req.resolution, model);
const metadata = (req && req.metadata) || {};
if (metadata.resolution) return resolutionFor(metadata.resolution, model);
if (req && req.size) return resolutionFor(req.size, model);
return defaultResolution(model);
}
function hasHailuoImage(req, hasInputReferenceFile) {
if (hasInputReferenceFile) return true;
const metadata = (req && req.metadata) || {};
return Boolean(
trimmed(req && req.input_reference) ||
trimmed(req && req.image) ||
(Array.isArray(req && req.images) && req.images.length) ||
metadata.first_frame_image ||
metadata.last_frame_image ||
metadata.subject_reference
);
}
const H3_MODEL = "MiniMax-H3";
const H3_MIN_DURATION = 4;
const H3_MAX_DURATION = 15;
const H3_DEFAULT_DURATION = 5;
const H3_MAX_FRAME_IMAGES = 2;
const H3_MAX_REFERENCE_IMAGES = 9;
const H3_MAX_REFERENCE_VIDEOS = 3;
const H3_MAX_REFERENCE_AUDIOS = 3;
const H3_MAX_INPUT_VIDEO_SECONDS = 15;
const H3_RATIOS = ["adaptive", "21:9", "16:9", "4:3", "1:1", "3:4", "9:16"];
// MiniMax-H3 speaks the /v2 video generation contract: a multimodal `content`
// array instead of flat frame fields, an explicit `ratio`, 768P/2K resolutions,
// a task id path parameter on query, and a `{task: {...}}` query envelope.
function isH3(model) {
return model === H3_MODEL;
}
function h3Duration(req) {
const raw = req.duration;
if (raw === undefined || raw === null || raw === "") return H3_DEFAULT_DURATION;
const seconds = Number(raw);
if (!Number.isInteger(seconds) || seconds < H3_MIN_DURATION || seconds > H3_MAX_DURATION) {
throw new Error(H3_MODEL + " duration must be an integer between " + H3_MIN_DURATION + " and " + H3_MAX_DURATION + " seconds");
}
return seconds;
}
function h3Resolution(req) {
const metadata = req.metadata || {};
const raw = trimmed(metadata.resolution) || trimmed(req.resolution) || trimmed(req.size);
if (!raw) return "768P";
const value = raw.toUpperCase();
if (value.includes("2K")) return "2K";
if (value.includes("768")) return "768P";
throw new Error(H3_MODEL + " resolution must be 768P or 2K");
}
function h3MediaItem(type, url, role) {
const item = { type: type, role: role };
item[type] = { url: url };
return item;
}
// Accepts a single value or an array; file placeholders stay objects and are
// resolved by the host after the body is built.
function h3MediaList(source, key) {
const raw = source[key];
if (raw === undefined || raw === null) return [];
const values = Array.isArray(raw) ? raw : [raw];
return values.filter(function (value) {
return value && typeof value === "object" ? true : Boolean(trimmed(value));
});
}
function h3FrameImages(req) {
const metadata = req.metadata || {};
const images = h3MediaList(req, "images");
if (images.length > H3_MAX_FRAME_IMAGES) throw new Error(H3_MODEL + " accepts at most " + H3_MAX_FRAME_IMAGES + " frame images");
const frames = [];
if (metadata.first_frame_image) frames.push(h3MediaItem("image_url", metadata.first_frame_image, "first_frame"));
if (metadata.last_frame_image) frames.push(h3MediaItem("image_url", metadata.last_frame_image, "last_frame"));
if (frames.length) return frames;
return images.map(function (url, index) {
return h3MediaItem("image_url", url, index === 0 ? "first_frame" : "last_frame");
});
}
function validateH3Content(items) {
let hasText = false;
let hasFrame = false;
let hasReference = false;
let firstFrames = 0;
let lastFrames = 0;
let referenceImages = 0;
let referenceVideos = 0;
let referenceAudios = 0;
let inputImages = 0;
for (const item of items) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const role = trimmed(item.role);
if (item.type === "text" && trimmed(item.text)) {
hasText = true;
continue;
}
if (item.type === "image_url") {
inputImages += 1;
if (!role || role === "first_frame") {
firstFrames += 1;
hasFrame = true;
} else if (role === "last_frame") {
lastFrames += 1;
hasFrame = true;
} else if (role === "middle_frame") {
hasFrame = true;
} else if (role === "reference_image") {
referenceImages += 1;
hasReference = true;
}
continue;
}
if (item.type === "video_url") {
referenceVideos += 1;
hasReference = true;
continue;
}
if (item.type === "audio_url") {
referenceAudios += 1;
hasReference = true;
}
}
if (!hasText) throw new Error(H3_MODEL + " requires a non-empty text item");
if (firstFrames > 1) throw new Error(H3_MODEL + " accepts at most one first_frame image");
if (lastFrames > 1) throw new Error(H3_MODEL + " accepts at most one last_frame image");
if (referenceImages > H3_MAX_REFERENCE_IMAGES) throw new Error(H3_MODEL + " accepts at most " + H3_MAX_REFERENCE_IMAGES + " reference images");
if (inputImages > H3_MAX_REFERENCE_IMAGES) throw new Error(H3_MODEL + " accepts at most " + H3_MAX_REFERENCE_IMAGES + " input images");
if (referenceVideos > H3_MAX_REFERENCE_VIDEOS) throw new Error(H3_MODEL + " accepts at most " + H3_MAX_REFERENCE_VIDEOS + " reference videos");
if (referenceAudios > H3_MAX_REFERENCE_AUDIOS) throw new Error(H3_MODEL + " accepts at most " + H3_MAX_REFERENCE_AUDIOS + " reference audios");
if (hasFrame && hasReference) throw new Error(H3_MODEL + " cannot mix frame images with reference media");
return items;
}
// metadata.content is the full multimodal passthrough; otherwise the content
// array is assembled from prompt, frame images, and reference media.
function h3Content(req) {
const metadata = req.metadata || {};
const prompt = trimmed(req.prompt);
if (metadata.content !== undefined && metadata.content !== null) {
if (!Array.isArray(metadata.content)) throw new Error("metadata.content must be an array");
const items = metadata.content;
const hasText = items.some(function (item) {
return item && item.type === "text" && trimmed(item.text);
});
if (hasText) return validateH3Content(items);
if (!prompt) throw new Error(H3_MODEL + " metadata.content requires a text item or a prompt");
return validateH3Content([{ type: "text", text: prompt }].concat(items));
}
const content = prompt ? [{ type: "text", text: prompt }] : [];
for (const frame of h3FrameImages(req)) content.push(frame);
const videos = h3MediaList(metadata, "reference_video");
if (videos.length > H3_MAX_REFERENCE_VIDEOS) throw new Error(H3_MODEL + " accepts at most " + H3_MAX_REFERENCE_VIDEOS + " reference videos");
for (const video of videos) content.push(h3MediaItem("video_url", video, "reference_video"));
const audios = h3MediaList(metadata, "reference_audio");
if (audios.length > H3_MAX_REFERENCE_AUDIOS) throw new Error(H3_MODEL + " accepts at most " + H3_MAX_REFERENCE_AUDIOS + " reference audios");
for (const audio of audios) content.push(h3MediaItem("audio_url", audio, "reference_audio"));
if (!content.length) throw new Error(H3_MODEL + " requires a prompt or a media input");
return validateH3Content(content);
}
function h3HasVisualContent(content) {
return content.some(function (item) {
return item && (item.type === "image_url" || item.type === "video_url");
});
}
// ratio is mandatory upstream and `adaptive` is only meaningful when the
// aspect ratio can be inherited from a visual input.
function h3Ratio(req, content) {
const metadata = req.metadata || {};
const ratio = trimmed(metadata.ratio);
if (!ratio) return h3HasVisualContent(content) ? "adaptive" : "16:9";
if (!H3_RATIOS.includes(ratio)) throw new Error(H3_MODEL + " ratio must be one of " + H3_RATIOS.join(", "));
if (ratio === "adaptive" && !h3HasVisualContent(content)) throw new Error(H3_MODEL + " ratio adaptive requires an image or video input");
return ratio;
}
function h3QueryTask(body) {
const task = body && typeof body === "object" && !Array.isArray(body) ? body.task : null;
return task && typeof task === "object" && !Array.isArray(task) ? task : null;
}
function h3APIError(body) {
const error = body && typeof body === "object" && !Array.isArray(body) ? body.error : null;
if (!error || typeof error !== "object" || Array.isArray(error)) return null;
const message = trimmed(error.message);
if (!message) return null;
const statusCode = Number(error.http_code || error.code || 0);
return { message: message, statusCode: Number.isInteger(statusCode) ? statusCode : 0 };
}
// Older T2V-01*/I2V-01*/S2V-01 official tables disagree on 1080P support (research: 未验证).
// Keep those models permissive: duration 6 only, resolution optional.
function validateHailuoCombo(model, duration, resolution, hasImage) {
if (isH3(model)) return;
if (model === "MiniMax-Hailuo-2.3-Fast" && !hasImage) {
throw new Error("MiniMax-Hailuo-2.3-Fast supports image-to-video only");
}
if (!isModernHailuo(model)) {
if (duration !== undefined && Number(duration) !== 6) throw new Error(model + " duration must be 6");
return;
}
const n = duration === undefined ? 6 : Number(duration);
if (n !== 6 && n !== 10) throw new Error(model + " duration must be 6 or 10");
if (n === 10) {
if (model === "MiniMax-Hailuo-02" && hasImage) {
if (resolution !== "768P" && resolution !== "512P") throw new Error("MiniMax-Hailuo-02 duration 10 only allows resolution 768P or 512P");
return;
}
if (resolution !== "768P") throw new Error(model + " duration 10 only allows resolution 768P");
return;
}
const allowed = model === "MiniMax-Hailuo-02" && hasImage ? ["512P", "768P", "1080P"] : ["768P", "1080P"];
if (allowed.indexOf(resolution) < 0) throw new Error(model + " duration 6 only allows resolution " + allowed.join(" or "));
}
function responsesInput(req) {
const texts = [],
images = [];
const input = req.input;
if (typeof input === "string") texts.push(input);
else if (Array.isArray(input)) {
for (const item of input) {
if (typeof item === "string") {
texts.push(item);
continue;
}
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const content = item.content === undefined ? [item] : Array.isArray(item.content) ? item.content : [item.content];
for (const part of content) {
if (typeof part === "string") {
texts.push(part);
continue;
}
if (!part || typeof part !== "object" || Array.isArray(part)) continue;
if (["input_text", "text"].includes(part.type) && typeof part.text === "string") texts.push(part.text);
if (["input_image", "image_url"].includes(part.type)) {
let image = part.image_url;
if (image && typeof image === "object") image = image.url;
if (trimmed(image)) images.push(trimmed(image));
}
}
}
}
return {
prompt: texts
.filter(function (text) {
return trimmed(text);
})
.join("\n"),
images: images,
};
}
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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
return '<video controls src="' + escaped + '"></video>';
}
export function buildSubmitRequest(ctx) {
const req = ctx.requestBody || {};
const model = ctx.upstreamModel;
const metadata = req.metadata || {};
if (isH3(model)) {
const content = h3Content(req);
const h3Body = {
model: model,
content: content,
resolution: h3Resolution(req),
duration: h3Duration(req),
ratio: h3Ratio(req, content),
};
["callback_url", "aigc_watermark"].forEach(function (key) {
if (metadata[key] !== undefined && metadata[key] !== null) h3Body[key] = metadata[key];
});
return {
url: ctx.baseUrl + "/v2/video_generation",
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: "Bearer " + ctx.apiKey },
body: h3Body,
action: h3HasVisualContent(content) ? "image_to_video" : "text_to_video",
};
}
const body = {
model: model,
prompt: req.prompt || undefined,
duration: outboundDuration(req),
resolution: outboundResolution(req, model),
};
["prompt_optimizer", "fast_pretreatment", "callback_url", "aigc_watermark", "first_frame_image", "last_frame_image", "subject_reference"].forEach(
function (key) {
if (metadata[key] !== undefined && metadata[key] !== null) body[key] = metadata[key];
}
);
return {
url: ctx.baseUrl + "/v1/video_generation",
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: "Bearer " + ctx.apiKey },
body: body,
action: hasHailuoImage(req, false) ? "image_to_video" : "text_to_video",
};
}
export function parseSubmitResponse(ctx, resp) {
const body = resp.body || {};
const apiError = isH3(ctx.upstreamModel) ? h3APIError(body) : null;
if (apiError) throw new Error(apiError.message);
const base = body.base_resp;
// /v1 always wraps the create response in a base_resp envelope; /v2 returns a
// bare task_id and only adds base_resp when the call is rejected.
if (base) {
if (base.status_code !== 0) throw new Error(base.status_msg || "hailuo submit failed");
} else if (!isH3(ctx.upstreamModel)) {
throw new Error("hailuo submit failed");
}
if (!body.task_id) throw new Error("missing task_id");
return { taskId: body.task_id, taskData: body };
}
export function extractUsage(ctx) {
if (ctx.usagePurpose === "billing_ratios") return null;
const req = ctx.requestBody || {};
const model = ctx.upstreamModel || req.model;
if (isH3(model)) {
const content = h3Content(req);
return {
seconds: h3Duration(req),
resolution: h3Resolution(req),
input_images: content.filter(function (item) {
return item && item.type === "image_url";
}).length,
// Input URLs do not expose duration. Reserve the documented total limit;
// polling replaces it with usage.input_seconds after success.
input_video_seconds: content.some(function (item) {
return item && item.type === "video_url";
})
? H3_MAX_INPUT_VIDEO_SECONDS
: 0,
};
}
return { seconds: outboundDuration(req), resolution: outboundResolution(req, model), input_images: 0, input_video_seconds: 0 };
}
export function buildQueryRequest(ctx) {
// Polling carries no relay info; the host fills these identities from the
// persisted task properties.
const path = isH3(ctx.upstreamModel || ctx.model)
? "/v2/query/video_generation/" + encodeURIComponent(ctx.taskId)
: "/v1/query/video_generation?task_id=" + encodeURIComponent(ctx.taskId);
return {
url: ctx.baseUrl + path,
method: "GET",
headers: { Accept: "application/json", Authorization: "Bearer " + ctx.apiKey },
};
}
export function parseTaskResult(ctx, body) {
const apiError = h3APIError(body);
if (apiError) {
if (apiError.statusCode === 408 || apiError.statusCode === 429 || apiError.statusCode >= 500) throw new Error(apiError.message);
return { code: apiError.statusCode, status: "FAILURE", progress: "100%", reason: apiError.message };
}
const h3Task = h3QueryTask(body);
if (h3Task) {
const h3Statuses = { queued: "QUEUED", running: "IN_PROGRESS", succeeded: "SUCCESS", failed: "FAILURE", cancelled: "FAILURE" };
const h3Status = h3Statuses[h3Task.status];
if (!h3Status) {
return { status: "UNKNOWN", reason: "unrecognized status: " + String(h3Task.status || "") };
}
const h3Result = { code: 0, status: h3Status, progress: h3Status === "QUEUED" ? "30%" : h3Status === "IN_PROGRESS" ? "50%" : "100%" };
if (h3Status === "SUCCESS") {
const url = trimmed(h3Task.content && h3Task.content.url);
if (url) h3Result.url = url;
}
if (h3Status === "FAILURE") {
h3Result.reason = trimmed(h3Task.error && h3Task.error.message) || "task " + trimmed(h3Task.status);
}
return h3Result;
}
if (body.base_resp && body.base_resp.status_code !== 0) {
return { code: body.base_resp.status_code || 0, status: "FAILURE", progress: "100%", reason: body.base_resp.status_msg || "" };
}
const base = body.base_resp || {};
const statuses = { Preparing: "IN_PROGRESS", Queueing: "IN_PROGRESS", Processing: "IN_PROGRESS", Success: "SUCCESS", Fail: "FAILURE" };
const status = statuses[body.status];
if (!status) {
return { status: "UNKNOWN", reason: "unrecognized status: " + String(body.status || "") };
}
const progress = status === "SUCCESS" || status === "FAILURE" ? "100%" : body.status === "Processing" ? "50%" : "30%";
const reason = status === "FAILURE" ? "task failed" : "";
return { code: base.status_code || 0, status: status, progress: progress, reason: reason };
}
function artifactData(ctx) {
const data = (ctx && ctx.data) || {};
if (data.data && typeof data.data === "object" && data.data.task_id && Object.prototype.hasOwnProperty.call(data.data, "data")) return data.data.data || {};
return data;
}
function artifactFileID(ctx) {
return trimmed(artifactData(ctx).file_id);
}
// /v2 tasks expose a public CDN URL instead of a downloadable file id.
function h3ArtifactURL(ctx) {
const task = h3QueryTask(artifactData(ctx));
return task ? trimmed(task.content && task.content.url) : "";
}
export function listArtifacts(task) {
if (task.status !== "SUCCESS") return [];
return artifactFileID(task) || h3ArtifactURL(task) ? [{ key: "video", type: "video", mimeType: "video/mp4" }] : [];
}
export function buildContentRequest(ctx) {
if (ctx.artifactKey !== "video") throw new Error("artifact_not_found");
const fileID = artifactFileID(ctx);
if (!fileID) {
const url = h3ArtifactURL(ctx);
if (!url) throw new Error("artifact_not_found");
return { url: url, method: ctx.clientRequest.method, credentialless: true };
}
return {
url: ctx.baseUrl + "/v1/files/download?file_id=" + encodeURIComponent(fileID),
method: ctx.clientRequest.method,
headers: { Accept: "video/*", Authorization: "Bearer " + ctx.apiKey },
};
}
export function extractUsageOnComplete(_task, _taskResult, body) {
const h3Task = h3QueryTask(body);
if (h3Task) {
const resolution = trimmed(h3Task.resolution).toUpperCase();
const facts = {};
if (resolution === "2K" || resolution === "768P") facts.resolution = resolution;
const usage = h3Task.usage && typeof h3Task.usage === "object" && !Array.isArray(h3Task.usage) ? h3Task.usage : {};
const fields = [
{ key: "seconds", value: usage.output_seconds, minimum: H3_MIN_DURATION, maximum: H3_MAX_DURATION, integer: false },
{ key: "input_images", value: usage.input_image_count, minimum: 0, maximum: H3_MAX_REFERENCE_IMAGES, integer: true },
{ key: "input_video_seconds", value: usage.input_seconds, minimum: 0, maximum: H3_MAX_INPUT_VIDEO_SECONDS, integer: false },
];
// Omit malformed or out-of-contract upstream values so settlement keeps
// the bounded submission estimate instead of accepting a new multiplier.
for (const field of fields) {
if (field.value === undefined || field.value === null || field.value === "") continue;
const value = Number(field.value);
if (!Number.isFinite(value) || value < field.minimum || value > field.maximum || (field.integer && !Number.isInteger(value))) continue;
facts[field.key] = value;
}
return Object.keys(facts).length ? facts : null;
}
const width = Number((body || {}).video_width || 0);
const height = Number((body || {}).video_height || 0);
if (!(width > 0) || !(height > 0)) return null;
return { resolution: resolutionFor(width + "x" + height, "") };
}
export const protocols = {
openai_responses: {
decodeRequest: function (ctx) {
if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
const req = ctx.body.value;
if (!req || typeof req !== "object" || Array.isArray(req)) throw new Error("request body must be an object");
const model = trimmed(req.model);
if (!model) throw new Error("model is required");
if (req.input !== undefined && typeof req.input !== "string" && !Array.isArray(req.input)) throw new Error("input must be a string or array");
if (req.images !== undefined && !Array.isArray(req.images)) throw new Error("images must be an array");
if (req.metadata !== undefined && (!req.metadata || typeof req.metadata !== "object" || Array.isArray(req.metadata)))
throw new Error("metadata must be an object");
const input = responsesInput(req);
const prompt = input.prompt || trimmed(req.prompt);
const images = [];
for (const image of [req.image, req.input_reference].concat(req.images || [], input.images)) {
if (trimmed(image) && !images.includes(trimmed(image))) images.push(trimmed(image));
}
if (!prompt && images.length === 0) throw new Error("input is required");
const metadata = Object.assign({}, req.metadata || {});
if (images.length && !metadata.first_frame_image) metadata.first_frame_image = images[0];
if (images.length > 1 && !metadata.last_frame_image) metadata.last_frame_image = images[1];
const requestBody = { model: model, prompt: prompt, metadata: metadata };
if (images.length) requestBody.images = images;
if (Object.prototype.hasOwnProperty.call(req, "seconds")) requestBody.duration = req.seconds;
else if (Object.prototype.hasOwnProperty.call(req, "duration")) requestBody.duration = req.duration;
if (Object.prototype.hasOwnProperty.call(req, "size")) requestBody.size = req.size;
else if (Object.prototype.hasOwnProperty.call(req, "resolution")) requestBody.size = req.resolution;
return { kind: "submit", model: model, action: images.length ? "image_to_video" : "text_to_video", requestBody: requestBody };
},
renderEvents: function (ctx, task, previousState) {
const status = String(task.status || "UNKNOWN").toUpperCase();
const value = Number(String(task.progress || "").replace("%", ""));
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 events = previousState && previousState.status === status ? [] : [{ type: "output", data: text }];
return { events: events, state: state, done: true };
}
if (status === "FAILURE")
return { events: [{ type: "error", code: "task_failed", message: task.fail_reason || "task failed" }], state: state, done: true };
if (previousState && previousState.status === status && previousState.progress === progress) return { events: [], state: state, done: false };
const event = { type: "progress", message: status.toLowerCase() };
if (progress !== null) event.progress = progress;
return { events: [event], state: state, done: false };
},
renderFinal: function (ctx, _task) {
return {
output: [
{
type: "message",
status: "completed",
role: "assistant",
content: [{ type: "output_text", text: responsesVideoText(ctx), annotations: [], logprobs: [] }],
},
],
metadata: { vendor: "hailuo" },
};
},
},
};
const legacyRenderers = {
openai_video: function (task) {
const statuses = { NOT_START: "queued", SUBMITTED: "queued", QUEUED: "queued", IN_PROGRESS: "in_progress", SUCCESS: "completed", FAILURE: "failed" };
const output = {
id: task.task_id,
object: "video",
model: task.properties && task.properties.origin_model_name ? task.properties.origin_model_name : "",
status: statuses[task.status] || "unknown",
progress: Number(String(task.progress || "0").replace("%", "")),
created_at: task.created_at,
};
if (task.updated_at) output.completed_at = task.updated_at;
if (task.data && task.data.base_resp && task.data.base_resp.status_code !== 0) {
output.error = { message: task.data.base_resp.status_msg, code: String(task.data.base_resp.status_code) };
}
return output;
},
};
protocols.openai_video = {
decodeRequest: function (ctx) {
if (!ctx.body || (ctx.body.kind !== "json" && ctx.body.kind !== "multipart")) throw new Error("JSON or multipart body required");
let req;
let hasInputReferenceFile = false;
if (ctx.body.kind === "json") {
if (!ctx.body.value || Array.isArray(ctx.body.value)) throw new Error("JSON object required");
req = Object.assign({}, ctx.body.value);
} else {
const first = function (name) {
const values = (ctx.body.fields || {})[name] || [];
if (values.length > 1) throw new Error(name + " must be provided once");
return values[0];
};
req = {};
const fields = ctx.body.fields || {};
for (const name of Object.keys(fields)) {
req[name] = first(name);
}
for (const file of ctx.body.files || []) {
if (file.field !== "input_reference") throw new Error("unexpected file field: " + file.field);
if (hasInputReferenceFile) throw new Error("input_reference must be provided once");
hasInputReferenceFile = true;
}
if (req.metadata !== undefined) {
let parsed;
try {
parsed = JSON.parse(req.metadata);
} catch (e) {
throw new Error("metadata must be a JSON object string");
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("metadata must be a JSON object string");
req.metadata = parsed;
}
if (req.seconds !== undefined) req.seconds = Number(req.seconds);
else if (req.duration !== undefined) req.seconds = Number(req.duration);
}
const seconds = req.seconds === undefined ? req.duration : req.seconds;
if (seconds !== undefined) req.duration = Number(seconds);
if (hasInputReferenceFile) {
req.metadata = Object.assign({}, req.metadata || {}, {
first_frame_image: { __fileRef: "request_file:input_reference", encoding: "dataUrl", maxBytes: 20971520 },
});
} else {
const image = trimmed(req.input_reference || req.image);
if (image) {
req.metadata = Object.assign({}, req.metadata || {});
if (!req.metadata.first_frame_image) req.metadata.first_frame_image = image;
}
}
const hasImage = hasHailuoImage(req, hasInputReferenceFile);
const duration = req.duration === undefined ? undefined : Number(req.duration);
const comboModel = ctx.upstreamModel || ctx.model;
validateHailuoCombo(comboModel, duration, outboundResolution(req, comboModel), hasImage);
return {
kind: "submit",
model: ctx.model,
action: hasImage ? "image_to_video" : "text_to_video",
requestBody: Object.assign({}, req, { model: ctx.model }),
};
},
render: function (ctx, task) {
return legacyRenderers.openai_video(task);
},
};
+562
View File
@@ -0,0 +1,562 @@
export const meta = {
apiVersion: 1,
key: "jimeng",
name: "Jimeng",
icon: "Jimeng.Color",
description: {
en: "Volcengine Jimeng video generation (text-to-video, image-to-video, and first-and-last-frame)",
zh: "火山引擎即梦视频生成(文生视频、图生视频、首尾帧)",
},
version: "1.0.2",
author: { name: "QuantumNous" },
channelTypes: [51],
models: ["jimeng_vgfm_t2v_l20"],
fetchMode: "per_task",
usageSchema: {
// Requested video duration in seconds. S2.0 Pro is fixed at 5; 3.0 req_keys allow 5 or 10.
seconds: {
type: "number",
unit: "second",
description: { en: "Video generation unit price", zh: "视频生成单价" },
},
// Product tier derived from the final outbound req_key.
product: {
enum: ["s2_pro", "v30_720p", "v30_1080p", "v30_pro"],
enumLabels: {
s2_pro: { en: "S2.0 Pro", zh: "S2.0 Pro" },
v30_720p: { en: "3.0 720p", zh: "3.0 720p" },
v30_1080p: { en: "3.0 1080p", zh: "3.0 1080p" },
v30_pro: { en: "3.0 Pro", zh: "3.0 Pro" },
},
description: { en: "Product tier", zh: "产品档位" },
},
},
usageExamples: [
{ label: "S2.0 Pro 5s", facts: { seconds: 5, product: "s2_pro" } },
{ label: "3.0 720P 5s", facts: { seconds: 5, product: "v30_720p" } },
{ label: "3.0 720P 10s", facts: { seconds: 10, product: "v30_720p" } },
{ label: "3.0 1080P 5s", facts: { seconds: 5, product: "v30_1080p" } },
{ label: "3.0 1080P 10s", facts: { seconds: 10, product: "v30_1080p" } },
{ label: "3.0 Pro 5s", facts: { seconds: 5, product: "v30_pro" } },
{ label: "3.0 Pro 10s", facts: { seconds: 10, product: "v30_pro" } },
],
protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
routes: [{ method: "POST", path: "/jimeng/", type: "dynamic", decode: "decodeRequest", render: "renderTask" }],
};
function trimmed(value) {
return String(value || "").trim();
}
function responsesInput(req) {
const texts = [];
const input = req.input;
if (typeof input === "string") texts.push(input);
else if (Array.isArray(input)) {
for (const item of input) {
if (typeof item === "string") {
texts.push(item);
continue;
}
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const content = item.content === undefined ? [item] : Array.isArray(item.content) ? item.content : [item.content];
for (const part of content) {
if (typeof part === "string") {
texts.push(part);
continue;
}
if (!part || typeof part !== "object" || Array.isArray(part)) continue;
if (["input_text", "text"].includes(part.type) && typeof part.text === "string") texts.push(part.text);
if (["input_image", "image_url"].includes(part.type)) throw new Error("Jimeng Responses supports text input only");
}
}
}
return texts
.filter(function (text) {
return trimmed(text);
})
.join("\n");
}
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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
return '<video controls src="' + escaped + '"></video>';
}
function isRelay(apiKey) {
return apiKey.startsWith("sk-");
}
function imageValues(body) {
const images = [];
if (Array.isArray(body.image_urls)) {
body.image_urls.forEach(function (image) {
images.push(image);
});
}
if (Array.isArray(body.binary_data_base64)) {
body.binary_data_base64.forEach(function (image) {
images.push(image);
});
}
if (images.length > 0) return images;
if (Array.isArray(body.images)) {
body.images.forEach(function (image) {
images.push(image);
});
}
if (images.length === 0 && typeof body.image === "string" && body.image.trim() !== "") {
images.push(body.image);
}
return images;
}
function actionForImageCount(imageCount) {
if (imageCount > 1) return "first_tail_to_video";
if (imageCount === 1) return "image_to_video";
return "text_to_video";
}
function decodeNativeRequest(ctx) {
if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
const requestBody = ctx.body.value;
const query = ctx.query || {};
const actions = query.Action || [];
const action = actions.length ? actions[0] : "";
if (!action) throw new Error("Action query parameter is required");
const body = requestBody && typeof requestBody === "object" ? requestBody : {};
if (action === "CVSync2AsyncGetResult") {
if (typeof body.task_id !== "string" || body.task_id.trim() === "") {
throw new Error("task_id is required for CVSync2AsyncGetResult");
}
return { kind: "query", taskIds: [body.task_id] };
}
if (action !== "CVSync2AsyncSubmitTask") {
throw new Error("unsupported Jimeng Action");
}
const images = imageValues(body);
return {
kind: "submit",
model: typeof body.req_key === "string" ? body.req_key : "",
action: actionForImageCount(images.length),
requestBody: {
model: typeof body.req_key === "string" ? body.req_key : "",
prompt: typeof body.prompt === "string" ? body.prompt : "",
images: images,
metadata: body,
},
};
}
function endpoint(baseUrl, apiKey, action) {
return baseUrl + (isRelay(apiKey) ? "/jimeng/" : "/") + "?Action=" + action + "&Version=2022-08-31";
}
function requestHeaders(ctx, method, url, bodyText) {
const headers = { "Content-Type": "application/json", Accept: "application/json" };
if (isRelay(ctx.apiKey)) {
headers.Authorization = "Bearer " + ctx.apiKey;
return headers;
}
const parts = ctx.apiKey.split("|");
if (parts.length !== 2) throw new Error("invalid api key format for jimeng: expected 'ak|sk'");
const signed = utils.volcSignV4({
Method: method,
URL: url,
Headers: { "Content-Type": "application/json" },
Body: bodyText,
AccessKey: parts[0].trim(),
SecretKey: parts[1].trim(),
Region: "cn-north-1",
Service: "cv",
});
Object.keys(signed).forEach(function (key) {
headers[key] = signed[key];
});
return headers;
}
const ASPECT_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"];
const ASPECT_RATIO_VALUES = [
["16:9", 16 / 9],
["9:16", 9 / 16],
["1:1", 1],
["4:3", 4 / 3],
["3:4", 3 / 4],
["21:9", 21 / 9],
];
function isV3ReqKey(reqKey) {
return String(reqKey || "").includes("v30");
}
function convertedReqKey(reqKey, imageCount) {
if (reqKey === "jimeng_vgfm_t2v_l20" && imageCount > 0) return "jimeng_vgfm_i2v_l20";
if (!reqKey.includes("jimeng_v30")) return reqKey;
if (reqKey === "jimeng_v30_pro") return "jimeng_ti2v_v30_pro";
if (imageCount > 1) return reqKey.replace("jimeng_v30", "jimeng_i2v_first_tail_v30").replace(/p$/, "");
if (imageCount === 1) return reqKey.replace("jimeng_v30", "jimeng_i2v_first_v30").replace(/p$/, "");
return reqKey.replace("jimeng_v30", "jimeng_t2v_v30");
}
function productForReqKey(reqKey) {
const key = String(reqKey || "");
if (key.includes("vgfm") && key.endsWith("_l20")) return "s2_pro";
if (key === "jimeng_ti2v_v30_pro" || key.includes("v30_pro")) return "v30_pro";
if (key.includes("1080")) return "v30_1080p";
if (key.includes("v30")) return "v30_720p";
return "s2_pro";
}
function submitImageCount(req) {
const metadata = (req && req.metadata) || {};
const binaryCount = Array.isArray(metadata.binary_data_base64) ? metadata.binary_data_base64.length : 0;
const urlCount = Array.isArray(metadata.image_urls) ? metadata.image_urls.length : 0;
if (binaryCount + urlCount > 0) return binaryCount + urlCount;
return Array.isArray(req && req.images) ? req.images.length : 0;
}
function submitReqKey(ctx) {
const req = (ctx && ctx.requestBody) || {};
const metadata = req.metadata || {};
const base = metadata.req_key || (ctx && ctx.upstreamModel) || req.model || "";
return convertedReqKey(String(base), submitImageCount(req)) || "jimeng_vgfm_t2v_l20";
}
function outboundSeconds(req, reqKey) {
if (!isV3ReqKey(reqKey)) return 5;
const metadata = (req && req.metadata) || {};
if (Number(metadata.frames) === 241) return 10;
if (Number(metadata.frames) === 121) return 5;
const seconds = Number(req && req.duration);
return seconds === 10 ? 10 : 5;
}
function aspectRatioFromSize(size) {
const raw = String(size || "").trim();
if (ASPECT_RATIOS.indexOf(raw) >= 0) return raw;
const parts = raw.toLowerCase().split("x");
if (parts.length !== 2) return "";
const width = Number(parts[0]);
const height = Number(parts[1]);
if (!(width > 0) || !(height > 0)) return "";
const value = width / height;
let best = "";
let bestDiff = Infinity;
for (let i = 0; i < ASPECT_RATIO_VALUES.length; i++) {
const diff = Math.abs(value - ASPECT_RATIO_VALUES[i][1]);
if (diff < bestDiff) {
bestDiff = diff;
best = ASPECT_RATIO_VALUES[i][0];
}
}
return best;
}
function outboundAspectRatio(req) {
const metadata = (req && req.metadata) || {};
if (ASPECT_RATIOS.indexOf(metadata.aspect_ratio) >= 0) return metadata.aspect_ratio;
if (ASPECT_RATIOS.indexOf(req && req.aspect_ratio) >= 0) return req.aspect_ratio;
if (req && req.size) return aspectRatioFromSize(req.size);
return "";
}
function filePlaceholder(image) {
if (!image || typeof image !== "object" || Array.isArray(image) || !image.__fileRef) return image;
const placeholder = { __fileRef: image.__fileRef, encoding: image.encoding };
if (image.mimeType) placeholder.mimeType = image.mimeType;
if (image.maxBytes !== undefined && image.maxBytes !== null) placeholder.maxBytes = image.maxBytes;
return placeholder;
}
function queryReqKey(ctx) {
const state = (ctx && ctx.state) || {};
if (typeof state.req_key === "string" && state.req_key.trim()) return state.req_key.trim();
if (ctx && ctx.action === "image_to_video") return "jimeng_vgfm_i2v_l20";
if (ctx && ctx.action === "first_tail_to_video") return "jimeng_i2v_first_tail_v30";
return "jimeng_vgfm_t2v_l20";
}
function validateSecondsForReqKey(reqKey, seconds) {
const n = Number(seconds);
if (isV3ReqKey(reqKey)) {
if (n !== 5 && n !== 10) throw new Error("seconds must be 5 or 10");
return n;
}
if (n !== 5) throw new Error("seconds must be 5");
return n;
}
function decodeImageCount(req, hasInputReferenceFile) {
if (hasInputReferenceFile) return Math.max(1, Array.isArray(req.images) ? req.images.length : 0);
if (Array.isArray(req.images) && req.images.length) return req.images.length;
if (trimmed(req.input_reference) || trimmed(req.image)) return 1;
return submitImageCount(req);
}
export function buildSubmitRequest(ctx) {
const req = ctx.requestBody || {};
const metadata = req.metadata || {};
const images = req.images || [];
const body = {
req_key: ctx.upstreamModel,
prompt: req.prompt || undefined,
seed: 0,
};
const aspectRatio = outboundAspectRatio(req);
if (aspectRatio) body.aspect_ratio = aspectRatio;
if (images.length) {
if (String(images[0]).startsWith("http")) body.image_urls = images;
else body.binary_data_base64 = images.map(filePlaceholder);
}
["req_key", "binary_data_base64", "image_urls", "prompt", "seed", "aspect_ratio", "frames"].forEach(function (key) {
if (metadata[key] === undefined || metadata[key] === null) return;
if (key === "aspect_ratio" && !String(metadata[key]).trim()) return;
body[key] = metadata[key];
});
if (Array.isArray(body.binary_data_base64)) body.binary_data_base64 = body.binary_data_base64.map(filePlaceholder);
const binaryCount = Array.isArray(body.binary_data_base64) ? body.binary_data_base64.length : 0;
const urlCount = Array.isArray(body.image_urls) ? body.image_urls.length : 0;
const metadataImageCount = binaryCount + urlCount;
const imageCount = metadataImageCount > 0 ? metadataImageCount : images.length;
body.req_key = convertedReqKey(body.req_key, imageCount);
if (isV3ReqKey(body.req_key) && (body.frames === undefined || body.frames === null)) {
body.frames = outboundSeconds(req, body.req_key) === 10 ? 241 : 121;
}
if (!isV3ReqKey(body.req_key)) delete body.frames;
const ordered = { req_key: body.req_key };
if (body.binary_data_base64 && body.binary_data_base64.length) ordered.binary_data_base64 = body.binary_data_base64;
if (body.image_urls && body.image_urls.length) ordered.image_urls = body.image_urls;
if (body.prompt) ordered.prompt = body.prompt;
ordered.seed = body.seed;
if (body.aspect_ratio) ordered.aspect_ratio = body.aspect_ratio;
if (body.frames) ordered.frames = body.frames;
const url = endpoint(ctx.baseUrl, ctx.apiKey, "CVSync2AsyncSubmitTask");
const bodyText = JSON.stringify(ordered);
return {
url: url,
method: "POST",
headers: requestHeaders(ctx, "POST", url, bodyText),
body: bodyText,
action: actionForImageCount(imageCount),
};
}
export function parseSubmitResponse(ctx, resp) {
const body = resp.body || {};
if (body.code !== 10000) throw new Error(body.message || "jimeng submit failed");
if (!body.data || !body.data.task_id) throw new Error("missing task_id");
return { taskId: body.data.task_id, taskData: Object.assign({}, body, { req_key: submitReqKey(ctx) }), state: { req_key: submitReqKey(ctx) } };
}
export function extractUsage(ctx) {
if (ctx.usagePurpose === "billing_ratios") return null;
const reqKey = submitReqKey(ctx);
return { seconds: outboundSeconds(ctx.requestBody || {}, reqKey), product: productForReqKey(reqKey) };
}
export function buildQueryRequest(ctx) {
const body = JSON.stringify({ req_key: queryReqKey(ctx), task_id: ctx.taskId });
const url = endpoint(ctx.baseUrl, ctx.apiKey, "CVSync2AsyncGetResult");
return { url: url, method: "POST", headers: requestHeaders(ctx, "POST", url, body), body: body };
}
export function parseTaskResult(ctx, body) {
const data = body.data || {};
if (body.code !== 10000) {
return { code: body.code || 0, status: "FAILURE", progress: "100%", reason: body.message || "" };
}
if (data.status === "in_queue") {
const result = { code: 0, status: "QUEUED", progress: "10%", reason: "" };
if (data.video_url) result.url = data.video_url;
return result;
}
if (data.status === "done") {
const result = { code: 0, status: "SUCCESS", progress: "100%", reason: "" };
if (data.video_url) result.url = data.video_url;
return result;
}
return { code: 0, status: "UNKNOWN", reason: "unrecognized status: " + String(data.status || "") };
}
function artifactData(ctx) {
const data = (ctx && ctx.data) || {};
if (data.data && typeof data.data === "object" && data.data.task_id && Object.prototype.hasOwnProperty.call(data.data, "data")) return data.data.data || {};
return data;
}
export function listArtifacts(task) {
const url = (artifactData(task).data || {}).video_url;
return task.status === "SUCCESS" && String(url || "").trim() ? [{ key: "video", type: "video" }] : [];
}
export function buildContentRequest(ctx) {
if (ctx.artifactKey !== "video") throw new Error("artifact_not_found");
const url = String((artifactData(ctx).data || {}).video_url || "").trim();
if (!url) throw new Error("artifact_not_found");
return { url: url, method: ctx.clientRequest.method, credentialless: true };
}
export function extractUsageOnComplete() {
return null;
}
export const protocols = {
openai_responses: {
decodeRequest: function (ctx) {
if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
const req = ctx.body.value;
if (!req || typeof req !== "object" || Array.isArray(req)) throw new Error("request body must be an object");
const model = trimmed(req.model);
if (!model) throw new Error("model is required");
if (req.input !== undefined && typeof req.input !== "string" && !Array.isArray(req.input)) throw new Error("input must be a string or array");
if (req.metadata !== undefined && (!req.metadata || typeof req.metadata !== "object" || Array.isArray(req.metadata)))
throw new Error("metadata must be an object");
const prompt = responsesInput(req) || trimmed(req.prompt);
if (!prompt) throw new Error("input is required");
const metadata = Object.assign({}, req.metadata || {});
delete metadata.binary_data_base64;
delete metadata.image_urls;
delete metadata.images;
delete metadata.image;
const requestBody = { model: model, prompt: prompt, metadata: metadata };
if (Object.prototype.hasOwnProperty.call(req, "seconds")) requestBody.duration = req.seconds;
else if (Object.prototype.hasOwnProperty.call(req, "duration")) requestBody.duration = req.duration;
return { kind: "submit", model: model, action: "text_to_video", requestBody: requestBody };
},
renderEvents: function (ctx, task, previousState) {
const status = String(task.status || "UNKNOWN").toUpperCase();
const value = Number(String(task.progress || "").replace("%", ""));
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 events = previousState && previousState.status === status ? [] : [{ type: "output", data: text }];
return { events: events, state: state, done: true };
}
if (status === "FAILURE")
return { events: [{ type: "error", code: "task_failed", message: task.fail_reason || "task failed" }], state: state, done: true };
if (previousState && previousState.status === status && previousState.progress === progress) return { events: [], state: state, done: false };
const event = { type: "progress", message: status.toLowerCase() };
if (progress !== null) event.progress = progress;
return { events: [event], state: state, done: false };
},
renderFinal: function (ctx, _task) {
return {
output: [
{
type: "message",
status: "completed",
role: "assistant",
content: [{ type: "output_text", text: responsesVideoText(ctx), annotations: [], logprobs: [] }],
},
],
metadata: { vendor: "jimeng" },
};
},
},
};
const legacyRenderers = {
openai_video: function (task) {
const statuses = { NOT_START: "queued", SUBMITTED: "queued", QUEUED: "queued", IN_PROGRESS: "in_progress", SUCCESS: "completed", FAILURE: "failed" };
const output = {
id: task.task_id,
object: "video",
model: "",
status: statuses[task.status] || "unknown",
progress: Number(String(task.progress || "0").replace("%", "")),
created_at: task.created_at,
};
if (task.updated_at) output.completed_at = task.updated_at;
if (task.data && task.data.code !== 10000) output.error = { message: task.data.message || "", code: String(task.data.code || 0) };
return output;
},
jimeng_native: function (tasks) {
const task = Array.isArray(tasks) ? tasks[0] : tasks;
const stored = task && task.data && typeof task.data === "object" ? task.data : {};
const response = Object.assign({}, stored);
const data = stored.data && typeof stored.data === "object" ? stored.data : {};
response.code = stored.code === undefined ? 10000 : stored.code;
response.data = Object.assign({}, data, { task_id: task.task_id });
return response;
},
};
protocols.openai_video = {
decodeRequest: function (ctx) {
if (!ctx.body || (ctx.body.kind !== "json" && ctx.body.kind !== "multipart")) throw new Error("JSON or multipart body required");
let req;
let hasInputReferenceFile = false;
if (ctx.body.kind === "json") {
if (!ctx.body.value || Array.isArray(ctx.body.value)) throw new Error("JSON object required");
req = Object.assign({}, ctx.body.value);
} else {
const first = function (name) {
const values = (ctx.body.fields || {})[name] || [];
if (values.length > 1) throw new Error(name + " must be provided once");
return values[0];
};
req = {};
const fields = ctx.body.fields || {};
for (const name of Object.keys(fields)) {
req[name] = first(name);
}
for (const file of ctx.body.files || []) {
if (file.field !== "input_reference") throw new Error("unexpected file field: " + file.field);
if (hasInputReferenceFile) throw new Error("input_reference must be provided once");
hasInputReferenceFile = true;
}
if (req.metadata !== undefined) {
let parsed;
try {
parsed = JSON.parse(req.metadata);
} catch (e) {
throw new Error("metadata must be a JSON object string");
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("metadata must be a JSON object string");
req.metadata = parsed;
}
if (req.seconds !== undefined) req.seconds = Number(req.seconds);
else if (req.duration !== undefined) req.seconds = Number(req.duration);
}
if (hasInputReferenceFile) {
req.images = [{ __fileRef: "request_file:input_reference", encoding: "base64", maxBytes: 4928307 }];
} else {
const image = trimmed(req.input_reference || req.image);
if (image && (!Array.isArray(req.images) || req.images.length === 0)) req.images = [image];
}
const seconds = req.seconds === undefined ? req.duration : req.seconds;
if (seconds !== undefined) {
req.duration = validateSecondsForReqKey(
convertedReqKey(String(ctx.upstreamModel || ctx.model || req.model || ""), decodeImageCount(req, hasInputReferenceFile)),
seconds
);
}
return {
kind: "submit",
model: ctx.model,
action: actionForImageCount(decodeImageCount(req, hasInputReferenceFile)),
requestBody: Object.assign({}, req, { model: ctx.model }),
};
},
render: function (ctx, task) {
return legacyRenderers.openai_video(task);
},
};
export const native = {
decodeRequest: decodeNativeRequest,
renderTask: function (ctx, tasks) {
return legacyRenderers.jimeng_native(tasks);
},
error: function (ctx, error) {
return { code: error.httpStatus, message: error.message };
},
};
+477
View File
@@ -0,0 +1,477 @@
export const meta = {
apiVersion: 1,
key: "kling",
name: "Kling",
icon: "Kling.Color",
description: {
en: "Kuaishou Kling video generation (text-to-video and image-to-video)",
zh: "快手可灵视频生成(文生视频、图生视频)",
},
version: "1.0.2",
author: { name: "QuantumNous" },
channelTypes: [50],
models: ["kling-v1", "kling-v1-6", "kling-v2-master"],
fetchMode: "per_task",
usageSchema: {
// Kling final unit deduction (estimated at submit, actual on completion).
units: {
type: "number",
unit: "credit",
description: { en: "Kling credit unit price", zh: "可灵资源包单位单价" },
},
},
usageExamples: [
{ label: "v1 std 5s", facts: { units: 1 } },
{ label: "v1 pro 5s", facts: { units: 3.5 } },
{ label: "v1-6 std 5s", facts: { units: 2 } },
{ label: "v1-6 pro 10s", facts: { units: 7 } },
{ label: "v2-master pro 5s", facts: { units: 10 } },
],
protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
routes: [
{ method: "POST", path: "/kling/v1/videos/text2video", type: "submit", action: "text_to_video", decode: "decodeSubmit", render: "taskCreated" },
{ method: "POST", path: "/kling/v1/videos/image2video", type: "submit", action: "image_to_video", decode: "decodeSubmit", render: "taskCreated" },
{ method: "GET", path: "/kling/v1/videos/text2video/:task_id", type: "query", render: "taskStatus" },
{ method: "GET", path: "/kling/v1/videos/image2video/:task_id", type: "query", render: "taskStatus" },
],
};
// Official unit consumption (units per output video second), not a currency price.
// Source: https://kling.ai/dev/pricing
const UNITS_PER_SECOND = {
"kling-v1": { std: 0.2, pro: 0.7 },
"kling-v1-6": { std: 0.4, pro: 0.7 },
"kling-v2-master": { pro: 2.0 },
};
function trimmed(value) {
return String(value || "").trim();
}
function responsesInput(req) {
const texts = [],
images = [];
const input = req.input;
if (typeof input === "string") texts.push(input);
else if (Array.isArray(input)) {
for (const item of input) {
if (typeof item === "string") {
texts.push(item);
continue;
}
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const content = item.content === undefined ? [item] : Array.isArray(item.content) ? item.content : [item.content];
for (const part of content) {
if (typeof part === "string") {
texts.push(part);
continue;
}
if (!part || typeof part !== "object" || Array.isArray(part)) continue;
if (["input_text", "text"].includes(part.type) && typeof part.text === "string") texts.push(part.text);
if (["input_image", "image_url"].includes(part.type)) {
let image = part.image_url;
if (image && typeof image === "object") image = image.url;
if (trimmed(image)) images.push(trimmed(image));
}
}
}
}
return {
prompt: texts
.filter(function (text) {
return trimmed(text);
})
.join("\n"),
images: images,
};
}
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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
return '<video controls src="' + escaped + '"></video>';
}
function isRelay(apiKey) {
return apiKey.startsWith("sk-");
}
function tokenFor(apiKey) {
if (isRelay(apiKey)) return apiKey;
const parts = apiKey.split("|");
if (parts.length !== 2) throw new Error("invalid api_key, required format is accessKey|secretKey");
const now = utils.unixNow();
return utils.jwtSignHS256({ iss: parts[0].trim(), exp: now + 1800, nbf: now - 5 }, parts[1].trim());
}
function pathFor(action) {
return action === "image_to_video" ? "/v1/videos/image2video" : "/v1/videos/text2video";
}
function urlFor(baseUrl, apiKey, action) {
return baseUrl + (isRelay(apiKey) ? "/kling" : "") + pathFor(action);
}
function aspectRatio(size) {
const ratios = { "1024x1024": "1:1", "512x512": "1:1", "1280x720": "16:9", "1920x1080": "16:9", "720x1280": "9:16", "1080x1920": "9:16" };
return ratios[size] || "1:1";
}
function submitModel(ctx, req) {
return (ctx && ctx.upstreamModel) || (ctx && ctx.model) || (req && req.model) || "kling-v1";
}
function resolveKlingMode(model, mode) {
const raw = trimmed(mode).toLowerCase();
if (model === "kling-v2-master") {
if (raw === "std") throw new Error("kling-v2-master does not support mode std");
if (raw && raw !== "pro") throw new Error("mode must be pro");
return "pro";
}
if (!raw) return "std";
if (raw !== "std" && raw !== "pro") throw new Error("mode must be std or pro");
return raw;
}
function perSecondRate(model, mode) {
const table = UNITS_PER_SECOND[model] || UNITS_PER_SECOND["kling-v1"];
if (table[mode] !== undefined) return table[mode];
if (table.pro !== undefined) return table.pro;
return table.std;
}
function estimateUnits(model, mode, durationSeconds) {
return perSecondRate(model, mode) * durationSeconds;
}
// Official current pages list 3–15s for new models; old-model duration "5"|"10"
// is unverifiable (research 2026-08-27). Keep permissive positive integers up to
// the host task duration bound.
function validateKlingDuration(value) {
const n = Number(value);
if (!Number.isInteger(n) || n <= 0 || n > 3600) throw new Error("seconds must be a positive integer at most 3600");
return n;
}
function outboundDuration(req) {
const n = Number(req && req.duration);
if (Number.isFinite(n) && n > 0) return n;
const metadata = (req && req.metadata) || {};
const fromMeta = Number(metadata.duration);
if (Number.isFinite(fromMeta) && fromMeta > 0) return fromMeta;
return 5;
}
function outboundMode(req, model) {
const metadata = (req && req.metadata) || {};
return resolveKlingMode(model, (req && req.mode) || metadata.mode);
}
function hasKlingImage(req, hasInputReferenceFile) {
if (hasInputReferenceFile) return true;
const metadata = (req && req.metadata) || {};
if (req && req.image && typeof req.image === "object" && !Array.isArray(req.image) && req.image.__fileRef) return true;
return Boolean(trimmed(req && req.input_reference) || trimmed(req && req.image) || metadata.image || metadata.image_tail);
}
function filePlaceholder(image) {
if (!image || typeof image !== "object" || Array.isArray(image) || !image.__fileRef) return image;
const placeholder = { __fileRef: image.__fileRef, encoding: image.encoding };
if (image.mimeType) placeholder.mimeType = image.mimeType;
if (image.maxBytes !== undefined && image.maxBytes !== null) placeholder.maxBytes = image.maxBytes;
return placeholder;
}
function decodeNativeSubmit(ctx) {
if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
const body = ctx.body.value;
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("request body must be an object");
let model = typeof body.model_name === "string" ? body.model_name : "";
if (model === "") model = typeof body.model === "string" ? body.model : "";
if (!model.trim()) throw new Error("model is required");
return {
kind: "submit",
model: model,
requestBody: {
model: model,
prompt: typeof body.prompt === "string" ? body.prompt : "",
metadata: body,
},
};
}
export const native = {
decodeSubmit: decodeNativeSubmit,
taskCreated: function (ctx, task) {
const result = task.data || {},
data = result.data || {};
return Object.assign({}, result, { data: Object.assign({}, data, { task_id: task.task_id }) });
},
taskStatus: function (ctx, task) {
if (task.data && typeof task.data === "object" && !Array.isArray(task.data)) {
const result = task.data,
data = result.data && typeof result.data === "object" ? result.data : {};
return Object.assign({}, result, { data: Object.assign({}, data, { task_id: task.task_id }) });
}
const statusMap = { NOT_START: "submitted", SUBMITTED: "submitted", QUEUED: "submitted", IN_PROGRESS: "processing", SUCCESS: "succeed", FAILURE: "failed" };
return { code: 0, data: { task_id: task.task_id, task_status: statusMap[task.status] || "submitted", task_status_msg: task.fail_reason || "" } };
},
error: function (ctx, error) {
return { code: error.code, message: error.message };
},
};
export function buildSubmitRequest(ctx) {
const req = ctx.requestBody;
const metadata = req.metadata || {};
const inferredAction = req.image || metadata.image || metadata.image_tail ? "image_to_video" : "text_to_video";
const action = ctx.action === "text_to_video" || ctx.action === "image_to_video" ? ctx.action : inferredAction;
const model = ctx.upstreamModel || "kling-v1";
const body = Object.assign(
{
prompt: req.prompt,
image: req.image,
mode: outboundMode(req, model),
duration: String(req.duration || 5),
aspect_ratio: aspectRatio(req.size),
model_name: model,
model: model,
cfg_scale: 0.5,
},
metadata
);
body.mode = outboundMode({ mode: body.mode, metadata: metadata }, model);
if (body.image) body.image = filePlaceholder(body.image);
if (body.image_tail) body.image_tail = filePlaceholder(body.image_tail);
if (!body.prompt) delete body.prompt;
if (!body.image) delete body.image;
return {
url: urlFor(ctx.baseUrl, ctx.apiKey, action),
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: "Bearer " + tokenFor(ctx.apiKey), "User-Agent": "kling-sdk/1.0" },
body: body,
action: action,
};
}
export function parseSubmitResponse(ctx, resp) {
const result = resp.body || {};
if (result.code !== 0) throw new Error(result.message || "kling submit failed");
if (!result.data || !result.data.task_id) throw new Error("missing task_id");
return { taskId: result.data.task_id, taskData: result };
}
export function extractUsage(ctx) {
if (ctx.usagePurpose === "billing_ratios") return null;
const req = ctx.requestBody || {};
const model = submitModel(ctx, req);
const duration = outboundDuration(req);
const mode = outboundMode(req, model);
return { units: estimateUnits(model, mode, duration) };
}
export function buildQueryRequest(ctx) {
return {
url: urlFor(ctx.baseUrl, ctx.apiKey, ctx.action) + "/" + ctx.taskId,
method: "GET",
headers: { Accept: "application/json", Authorization: "Bearer " + tokenFor(ctx.apiKey), "User-Agent": "kling-sdk/1.0" },
};
}
export function parseTaskResult(ctx, body) {
const data = body.data || {};
const statuses = { submitted: "SUBMITTED", processing: "IN_PROGRESS", succeed: "SUCCESS", failed: "FAILURE" };
const status = statuses[data.task_status];
if (!status) return { status: "UNKNOWN", reason: "unknown task status: " + String(data.task_status || "") };
const videos = status === "SUCCESS" && data.task_result && data.task_result.videos ? data.task_result.videos : [];
const result = { code: body.code || 0, taskId: data.task_id, status: status, reason: data.task_status_msg || "" };
if (videos.length && videos[0].url) result.url = videos[0].url;
const units = Number.parseFloat(data.final_unit_deduction || "");
if (Number.isFinite(units) && units > 0) {
result.completionTokens = Math.ceil(units);
result.totalTokens = Math.ceil(units);
}
return result;
}
function artifactData(ctx) {
const data = (ctx && ctx.data) || {};
if (data.data && typeof data.data === "object" && data.data.task_id && Object.prototype.hasOwnProperty.call(data.data, "data")) return data.data.data || {};
return data;
}
function artifactVideoURL(ctx) {
const result = (artifactData(ctx).data || {}).task_result || {};
const videos = Array.isArray(result.videos) ? result.videos : [];
return videos.length ? String(videos[0].url || "").trim() : "";
}
export function listArtifacts(task) {
return task.status === "SUCCESS" && artifactVideoURL(task) ? [{ key: "video", type: "video" }] : [];
}
export function buildContentRequest(ctx) {
if (ctx.artifactKey !== "video") throw new Error("artifact_not_found");
const url = artifactVideoURL(ctx);
if (!url) throw new Error("artifact_not_found");
return { url: url, method: ctx.clientRequest.method, credentialless: true };
}
export function extractUsageOnComplete(_task, _taskResult, body) {
const data = (body && body.data) || {};
if (
!Object.prototype.hasOwnProperty.call(data, "final_unit_deduction") ||
data.final_unit_deduction === undefined ||
data.final_unit_deduction === null ||
data.final_unit_deduction === ""
) {
return null;
}
const units = Number.parseFloat(data.final_unit_deduction);
if (!Number.isFinite(units)) return null;
return { units: units };
}
export const protocols = {
openai_responses: {
decodeRequest: function (ctx) {
if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
const req = ctx.body.value;
if (!req || typeof req !== "object" || Array.isArray(req)) throw new Error("request body must be an object");
const model = trimmed(ctx.model);
if (!model) throw new Error("model is required");
if (req.input !== undefined && typeof req.input !== "string" && !Array.isArray(req.input)) throw new Error("input must be a string or array");
if (req.images !== undefined && !Array.isArray(req.images)) throw new Error("images must be an array");
if (req.metadata !== undefined && (!req.metadata || typeof req.metadata !== "object" || Array.isArray(req.metadata)))
throw new Error("metadata must be an object");
const input = responsesInput(req);
const prompt = input.prompt || trimmed(req.prompt);
const images = [];
for (const image of [req.image, req.input_reference].concat(req.images || [], input.images)) {
if (trimmed(image) && !images.includes(trimmed(image))) images.push(trimmed(image));
}
if (!prompt && images.length === 0) throw new Error("input is required");
const metadata = Object.assign({}, req.metadata || {});
if (Object.prototype.hasOwnProperty.call(req, "mode")) metadata.mode = req.mode;
metadata.mode = resolveKlingMode(ctx.upstreamModel || model, metadata.mode);
if (images.length > 1 && !metadata.image_tail) metadata.image_tail = images[1];
const requestBody = { model: model, prompt: prompt, metadata: metadata };
if (images.length) requestBody.image = images[0];
if (Object.prototype.hasOwnProperty.call(req, "seconds")) requestBody.duration = req.seconds;
else if (Object.prototype.hasOwnProperty.call(req, "duration")) requestBody.duration = req.duration;
if (Object.prototype.hasOwnProperty.call(req, "size")) requestBody.size = req.size;
return { kind: "submit", model: model, action: images.length ? "image_to_video" : "text_to_video", requestBody: requestBody };
},
renderEvents: function (ctx, task, previousState) {
const status = String(task.status || "UNKNOWN").toUpperCase();
const value = Number(String(task.progress || "").replace("%", ""));
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 events = previousState && previousState.status === status ? [] : [{ type: "output", data: text }];
return { events: events, state: state, done: true };
}
if (status === "FAILURE")
return { events: [{ type: "error", code: "task_failed", message: task.fail_reason || "task failed" }], state: state, done: true };
if (previousState && previousState.status === status && previousState.progress === progress) return { events: [], state: state, done: false };
const event = { type: "progress", message: status.toLowerCase() };
if (progress !== null) event.progress = progress;
return { events: [event], state: state, done: false };
},
renderFinal: function (ctx, _task) {
return {
output: [
{
type: "message",
status: "completed",
role: "assistant",
content: [{ type: "output_text", text: responsesVideoText(ctx), annotations: [], logprobs: [] }],
},
],
metadata: { vendor: "kling" },
};
},
},
openai_video: {
decodeRequest: function (ctx) {
if (!ctx.body || (ctx.body.kind !== "json" && ctx.body.kind !== "multipart")) throw new Error("JSON or multipart body required");
let req;
let hasInputReferenceFile = false;
if (ctx.body.kind === "json") {
if (!ctx.body.value || Array.isArray(ctx.body.value)) throw new Error("JSON object required");
req = Object.assign({}, ctx.body.value);
} else {
const first = function (name) {
const values = (ctx.body.fields || {})[name] || [];
if (values.length > 1) throw new Error(name + " must be provided once");
return values[0];
};
req = {};
const fields = ctx.body.fields || {};
for (const name of Object.keys(fields)) {
req[name] = first(name);
}
for (const file of ctx.body.files || []) {
if (file.field !== "input_reference") throw new Error("unexpected file field: " + file.field);
if (hasInputReferenceFile) throw new Error("input_reference must be provided once");
hasInputReferenceFile = true;
}
if (req.metadata !== undefined) {
let parsed;
try {
parsed = JSON.parse(req.metadata);
} catch (e) {
throw new Error("metadata must be a JSON object string");
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("metadata must be a JSON object string");
req.metadata = parsed;
}
if (req.seconds !== undefined) req.seconds = Number(req.seconds);
else if (req.duration !== undefined) req.seconds = Number(req.duration);
}
const seconds = req.seconds === undefined ? req.duration : req.seconds;
if (seconds !== undefined) req.duration = validateKlingDuration(seconds);
else req.duration = 5;
if (hasInputReferenceFile) {
req.image = { __fileRef: "request_file:input_reference", encoding: "base64", maxBytes: 10485760 };
} else {
const image = trimmed(req.input_reference || req.image);
if (image) req.image = image;
}
const model = ctx.upstreamModel || ctx.model || req.model || "kling-v1";
const metadata = req.metadata || {};
req.mode = resolveKlingMode(model, req.mode || metadata.mode);
const hasImage = hasKlingImage(req, hasInputReferenceFile);
return {
kind: "submit",
model: ctx.model,
action: hasImage ? "image_to_video" : "text_to_video",
requestBody: Object.assign({}, req, { model: ctx.model }),
};
},
render: function (ctx, task) {
const response = task.data || {};
const data = response.data || {};
const statusMap = { NOT_START: "queued", SUBMITTED: "queued", QUEUED: "queued", IN_PROGRESS: "in_progress", SUCCESS: "completed", FAILURE: "failed" };
const output = {
id: task.task_id,
object: "video",
model: "",
status: statusMap[task.status] || "unknown",
progress: Number(String(task.progress || "0").replace("%", "")),
created_at: data.created_at || 0,
};
if (data.updated_at) output.completed_at = data.updated_at;
const videos = data.task_result && data.task_result.videos ? data.task_result.videos : [];
if (videos.length) {
if (videos[0].duration) output.seconds = videos[0].duration;
}
if (response.code !== 0 && response.message) output.error = { message: response.message, code: String(response.code) };
if (data.task_status === "failed") output.error = { message: data.task_status_msg, code: "" };
return output;
},
},
};
+314
View File
@@ -0,0 +1,314 @@
export const meta = {
apiVersion: 1,
key: "sora",
name: "Sora",
icon: "Sora.Color",
description: {
en: "OpenAI Sora video generation (text-to-video, image-to-video, and remix)",
zh: "OpenAI Sora 视频生成(文生视频、图生视频、remix)",
},
version: "1.0.2",
channelTypes: [55, 1], // OpenAI-type channels natively serve sora with the same wire format
author: { name: "QuantumNous" },
models: ["sora-2", "sora-2-pro"],
fetchMode: "per_task",
usageSchema: {
// Requested video duration in seconds.
seconds: {
type: "number",
unit: "second",
description: { en: "Video generation unit price", zh: "视频生成单价" },
},
// Requested output video dimensions.
size: {
enum: ["720x1280", "1280x720", "1792x1024", "1024x1792"],
enumLabels: {
"720x1280": { en: "720x1280", zh: "720x1280" },
"1280x720": { en: "1280x720", zh: "1280x720" },
"1792x1024": { en: "1792x1024", zh: "1792x1024" },
"1024x1792": { en: "1024x1792", zh: "1024x1792" },
},
description: { en: "Output video dimensions", zh: "输出视频尺寸" },
},
},
protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
};
function trimmed(value) {
return String(value || "").trim();
}
function responsesInput(req) {
const texts = [],
images = [];
const input = req.input;
if (typeof input === "string") texts.push(input);
else if (Array.isArray(input)) {
for (const item of input) {
if (typeof item === "string") {
texts.push(item);
continue;
}
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const content = item.content === undefined ? [item] : Array.isArray(item.content) ? item.content : [item.content];
for (const part of content) {
if (typeof part === "string") {
texts.push(part);
continue;
}
if (!part || typeof part !== "object" || Array.isArray(part)) continue;
if (["input_text", "text"].includes(part.type) && typeof part.text === "string") texts.push(part.text);
if (["input_image", "image_url"].includes(part.type)) {
let image = part.image_url;
if (image && typeof image === "object") image = image.url;
if (trimmed(image)) images.push(trimmed(image));
}
}
}
}
return {
prompt: texts
.filter(function (text) {
return trimmed(text);
})
.join("\n"),
images: images,
};
}
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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
return '<video controls src="' + escaped + '"></video>';
}
function requestValues(req, model) {
const values = Object.assign({}, req || {});
values.model = model;
return values;
}
export function buildSubmitRequest(ctx) {
const req = ctx.requestBody || {};
if (!String(req.prompt || "").trim()) throw new Error("field prompt is required");
const action = ctx.action === "remix" ? "remix" : ctx.action;
const headers = { Authorization: "Bearer " + ctx.apiKey };
if (action === "remix") {
headers["Content-Type"] = "application/json";
return { url: ctx.baseUrl + "/v1/videos/" + ctx.originTaskId + "/remix", method: "POST", headers, body: requestValues(req, ctx.upstreamModel), action };
}
if ((ctx.files || []).length) {
const parts = [];
const values = requestValues(req, ctx.upstreamModel);
for (const key of Object.keys(values)) {
if (values[key] !== undefined && values[key] !== null && typeof values[key] !== "object") parts.push({ name: key, value: values[key] });
}
if (values.metadata && typeof values.metadata === "object" && !Array.isArray(values.metadata)) {
parts.push({ name: "metadata", value: JSON.stringify(values.metadata) });
}
for (const file of ctx.files) parts.push({ name: file.field, fileRef: file.ref, filename: file.filename });
return { url: ctx.baseUrl + "/v1/videos", method: "POST", headers, bodyType: "multipart", parts };
}
headers["Content-Type"] = "application/json";
return { url: ctx.baseUrl + "/v1/videos", method: "POST", headers, body: requestValues(req, ctx.upstreamModel) };
}
export function parseSubmitResponse(ctx, resp) {
const body = resp.body || {};
const taskId = body.id || body.task_id;
if (!taskId) throw new Error("task_id is empty");
return { taskId, taskData: body };
}
export function extractUsage(ctx) {
if (ctx.action === "remix") return {};
const req = ctx.requestBody || {};
let seconds = Number(req.seconds || req.duration || 4);
if (!Number.isFinite(seconds) || seconds <= 0) seconds = 4;
return { seconds: Math.min(seconds, 3600), size: req.size || "720x1280" };
}
export function extractUsageOnComplete(task, taskResult, body) {
const facts = {};
const seconds = Number((body || {}).seconds || (body || {}).duration || 0);
if (Number.isFinite(seconds) && seconds > 0) facts.seconds = Math.min(seconds, 3600);
const size = trimmed((body || {}).size);
if (["720x1280", "1280x720", "1792x1024", "1024x1792"].includes(size)) facts.size = size;
return facts;
}
export function buildQueryRequest(ctx) {
return { url: ctx.baseUrl + "/v1/videos/" + ctx.taskId, method: "GET", headers: { Authorization: "Bearer " + ctx.apiKey } };
}
export function parseTaskResult(ctx, body) {
const statuses = {
queued: "QUEUED",
pending: "QUEUED",
processing: "IN_PROGRESS",
in_progress: "IN_PROGRESS",
completed: "SUCCESS",
failed: "FAILURE",
cancelled: "FAILURE",
};
const mapped = statuses[body.status];
const result = { status: mapped || "UNKNOWN" };
if (!mapped) result.reason = "unrecognized status: " + String(body.status || "");
if (body.progress > 0 && body.progress < 100) result.progress = body.progress + "%";
if (result.status === "FAILURE") result.reason = body.error && body.error.message ? body.error.message : "task failed";
return result;
}
export function listArtifacts(task) {
return task.status === "SUCCESS" ? [{ key: "video", type: "video" }] : [];
}
export function buildContentRequest(ctx) {
if (ctx.artifactKey !== "video") throw new Error("artifact_not_found");
return {
url: ctx.baseUrl + "/v1/videos/" + encodeURIComponent(ctx.upstreamTaskId) + "/content",
method: ctx.clientRequest.method,
headers: { Authorization: "Bearer " + ctx.apiKey },
};
}
export const protocols = {
openai_responses: {
decodeRequest: function (ctx) {
if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
const req = ctx.body.value;
if (!req || typeof req !== "object" || Array.isArray(req)) throw new Error("request body must be an object");
const model = trimmed(req.model);
if (!model) throw new Error("model is required");
if (req.input !== undefined && typeof req.input !== "string" && !Array.isArray(req.input)) throw new Error("input must be a string or array");
if (req.images !== undefined && !Array.isArray(req.images)) throw new Error("images must be an array");
if (req.metadata !== undefined && (!req.metadata || typeof req.metadata !== "object" || Array.isArray(req.metadata)))
throw new Error("metadata must be an object");
const input = responsesInput(req);
const prompt = input.prompt || trimmed(req.prompt);
if (!prompt) throw new Error("input is required");
const images = [];
for (const image of [req.image, req.input_reference].concat(req.images || [], input.images)) {
if (trimmed(image) && !images.includes(trimmed(image))) images.push(trimmed(image));
}
const requestBody = { model: model, prompt: prompt };
if (images.length) requestBody.input_reference = images[0];
if (Object.prototype.hasOwnProperty.call(req, "seconds")) requestBody.seconds = req.seconds;
else if (Object.prototype.hasOwnProperty.call(req, "duration")) requestBody.seconds = req.duration;
if (Object.prototype.hasOwnProperty.call(req, "size")) requestBody.size = req.size;
if (Object.prototype.hasOwnProperty.call(req, "metadata")) requestBody.metadata = req.metadata;
return { kind: "submit", model: model, action: images.length ? "image_to_video" : "text_to_video", requestBody: requestBody };
},
renderEvents: function (ctx, task, previousState) {
const status = String(task.status || "UNKNOWN").toUpperCase();
const value = Number(String(task.progress || "").replace("%", ""));
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 events = previousState && previousState.status === status ? [] : [{ type: "output", data: text }];
return { events: events, state: state, done: true };
}
if (status === "FAILURE")
return { events: [{ type: "error", code: "task_failed", message: task.fail_reason || "task failed" }], state: state, done: true };
if (previousState && previousState.status === status && previousState.progress === progress) return { events: [], state: state, done: false };
const event = { type: "progress", message: status.toLowerCase() };
if (progress !== null) event.progress = progress;
return { events: [event], state: state, done: false };
},
renderFinal: function (ctx, _task) {
return {
output: [
{
type: "message",
status: "completed",
role: "assistant",
content: [{ type: "output_text", text: responsesVideoText(ctx), annotations: [], logprobs: [] }],
},
],
metadata: { vendor: "sora" },
};
},
},
};
const legacyRenderers = {
openai_video: function (task) {
const statuses = { NOT_START: "queued", SUBMITTED: "queued", QUEUED: "queued", IN_PROGRESS: "in_progress", SUCCESS: "completed", FAILURE: "failed" };
const output = {
id: task.task_id,
object: "video",
model: (task.properties || {}).origin_model_name || "",
status: statuses[task.status] || "unknown",
progress: Number(String(task.progress || "0").replace("%", "")),
created_at: Number(task.created_at || 0),
};
const completedAt = Number(task.finished_at || task.updated_at || 0);
if (completedAt > 0) output.completed_at = completedAt;
if (task.status === "FAILURE") {
output.error = { code: "video_generation_failed", message: "The video generation task failed." };
}
return output;
},
};
protocols.openai_video = {
decodeRequest: function (ctx) {
if (!ctx.body || (ctx.body.kind !== "json" && ctx.body.kind !== "multipart")) throw new Error("JSON or multipart body required");
if (ctx.body.kind === "json") {
if (!ctx.body.value || Array.isArray(ctx.body.value)) throw new Error("JSON object required");
const req = ctx.body.value;
const seconds = req.seconds === undefined ? req.duration : req.seconds;
if (seconds !== undefined && (!Number.isFinite(Number(seconds)) || Number(seconds) <= 0 || Number(seconds) > 3600))
throw new Error("seconds must be between 1 and 3600");
return {
kind: "submit",
model: ctx.model,
action: req.input_reference || req.image ? "image_to_video" : "text_to_video",
requestBody: Object.assign({}, req, { model: ctx.model }),
};
}
const first = function (name) {
const values = (ctx.body.fields || {})[name] || [];
if (values.length > 1) throw new Error(name + " must be provided once");
return values[0];
};
const req = {};
const fields = ctx.body.fields || {};
for (const name of Object.keys(fields)) {
req[name] = first(name);
}
let hasInputReferenceFile = false;
for (const file of ctx.body.files || []) {
if (file.field !== "input_reference") throw new Error("unexpected file field: " + file.field);
if (hasInputReferenceFile) throw new Error("input_reference must be provided once");
hasInputReferenceFile = true;
}
if (req.metadata !== undefined) {
let parsed;
try {
parsed = JSON.parse(req.metadata);
} catch (e) {
throw new Error("metadata must be a JSON object string");
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("metadata must be a JSON object string");
req.metadata = parsed;
}
if (req.seconds !== undefined) req.seconds = Number(req.seconds);
else if (req.duration !== undefined) req.seconds = Number(req.duration);
const seconds = req.seconds === undefined ? req.duration : req.seconds;
if (seconds !== undefined && (!Number.isFinite(Number(seconds)) || Number(seconds) <= 0 || Number(seconds) > 3600))
throw new Error("seconds must be between 1 and 3600");
return {
kind: "submit",
model: ctx.model,
action: hasInputReferenceFile || req.input_reference || req.image ? "image_to_video" : "text_to_video",
requestBody: Object.assign({}, req, { model: ctx.model }),
};
},
render: function (ctx, task) {
return legacyRenderers.openai_video(task);
},
};
+351
View File
@@ -0,0 +1,351 @@
// Unofficial plugin
// https://github.com/Suno-API/Suno-API
export const meta = {
apiVersion: 1,
key: "sunoapi",
name: "SunoAPI",
icon: "text",
description: {
en: "SunoAPI project music and lyrics generation",
zh: "SunoAPI 项目 音乐与歌词生成",
},
version: "1.0.3",
author: { name: "QuantumNous" },
channelTypes: [36],
models: ["suno_music", "suno_lyrics"],
fetchMode: "batch",
usageSchema: {
// Number of generated music or lyrics clips.
clips: {
type: "number",
unit: "count",
description: { en: "Song or lyrics generation unit price", zh: "生成歌曲或歌词单价" },
},
// Suno generation action.
action: {
enum: ["music", "lyrics"],
enumLabels: { music: { en: "Generate songs", zh: "生成歌曲" }, lyrics: { en: "Generate lyrics", zh: "生成歌词" } },
description: { en: "Generate songs or lyrics", zh: "生成歌曲或歌词" },
},
},
protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }],
routes: [
{ method: "POST", path: "/suno/submit/:action", type: "submit", decode: "decodeSubmit", render: "renderSubmit" },
{ method: "POST", path: "/suno/fetch", type: "dynamic", decode: "decodeBatch", render: "renderTasks" },
{ method: "GET", path: "/suno/fetch/:task_id", type: "query", render: "renderTask" },
],
};
function trimmed(value) {
return String(value || "").trim();
}
function responsesText(req) {
const texts = [];
const input = req.input;
if (typeof input === "string") texts.push(input);
else if (Array.isArray(input)) {
for (const item of input) {
if (typeof item === "string") {
texts.push(item);
continue;
}
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const content = item.content === undefined ? [item] : Array.isArray(item.content) ? item.content : [item.content];
for (const part of content) {
if (typeof part === "string") {
texts.push(part);
continue;
}
if (!part || typeof part !== "object" || Array.isArray(part)) continue;
if (["input_text", "text"].includes(part.type) && typeof part.text === "string") texts.push(part.text);
}
}
}
return texts
.filter(function (text) {
return trimmed(text);
})
.join("\n");
}
function actionName(ctx) {
return String((ctx.params || {}).action || ctx.action || "").toUpperCase();
}
function decodeNativeSubmit(ctx) {
if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
const action = actionName(ctx);
if (action !== "MUSIC" && action !== "LYRICS") throw new Error("invalid_action");
return {
kind: "submit",
model: action === "MUSIC" ? "suno_music" : "suno_lyrics",
action: action,
requestBody: ctx.body.value,
};
}
function decodeNativeBatch(ctx) {
if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
const body = ctx.body.value || {};
return { kind: "query", taskIds: Array.isArray(body.ids) ? body.ids : [] };
}
function validateAndNormalize(ctx) {
const body = Object.assign({}, ctx.requestBody || {});
if (body.make_instrumental === undefined) body.make_instrumental = false;
const action = actionName(ctx);
if (action === "MUSIC") {
if (!body.mv) body.mv = "chirp-v3-0";
} else if (action === "LYRICS") {
if (!body.prompt) throw new Error("prompt_empty");
} else {
throw new Error("invalid_action");
}
return { action: action, body: body };
}
export function buildSubmitRequest(ctx) {
const normalized = validateAndNormalize(ctx);
const incoming = ctx.requestHeaders || {};
const headers = { Authorization: "Bearer " + ctx.apiKey };
if (typeof incoming["Content-Type"] === "string" && incoming["Content-Type"]) headers["Content-Type"] = incoming["Content-Type"];
if (typeof incoming.Accept === "string" && incoming.Accept) headers.Accept = incoming.Accept;
return {
url: ctx.baseUrl + "/suno/submit/" + normalized.action,
method: "POST",
headers: headers,
body: normalized.body,
action: normalized.action,
};
}
export function parseSubmitResponse(ctx, resp) {
const body = resp.body || {};
if (body.code !== "success") throw new Error(String(body.message || ""));
if (!body.data) throw new Error("task_id is empty");
// The native create presenter runs after persistence, so retaining the
// acknowledgement message is required to preserve Suno's submit envelope.
// The first pre-poll status view therefore contains this temporary message;
// the first provider poll replaces task.data with the normal Suno payload.
return { taskId: body.data, taskData: { message: String(body.message || "") } };
}
export function extractUsage(ctx) {
if (ctx.usagePurpose === "billing_ratios") return null;
const model = trimmed(ctx.upstreamModel || ctx.model || (ctx.requestBody || {}).model).toLowerCase();
const action = actionName(ctx).toLowerCase() || (model === "suno_lyrics" ? "lyrics" : "music");
return { clips: action === "lyrics" ? 1 : 2, action: action };
}
export function buildBatchQueryRequest(ctx, tasks) {
return {
url: ctx.baseUrl + "/suno/fetch",
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer " + ctx.apiKey },
body: {
ids: (tasks || []).map(function (task) {
return task.taskId;
}),
},
};
}
// Required v1 per-task hooks remain defined for contract compatibility. Suno's
// host polling path uses the batch hooks below.
export function buildQueryRequest(ctx) {
return buildBatchQueryRequest(ctx, [ctx]);
}
export function parseBatchResult(ctx, body) {
if (body.code !== "success") throw new Error(String(body.message || ""));
return (body.data || []).map(function (item) {
return {
taskId: item.task_id || "",
action: item.action || "",
status: item.status || "",
reason: item.fail_reason || "",
submitTime: item.submit_time || 0,
startTime: item.start_time || 0,
finishTime: item.finish_time || 0,
data: item.data,
};
});
}
export function parseTaskResult(ctx, body) {
const results = parseBatchResult(ctx, { code: 200, data: [body] });
return results.length ? results[0] : { status: "UNKNOWN" };
}
function artifactData(ctx) {
const data = (ctx && ctx.data) || [];
if (data.data && typeof data.data === "object" && data.data.task_id && Object.prototype.hasOwnProperty.call(data.data, "data")) {
const nested = data.data.data;
return Array.isArray(nested) ? nested : nested && typeof nested === "object" ? [nested] : [];
}
return Array.isArray(data) ? data : data && typeof data === "object" ? [data] : [];
}
function artifactKey(type, song) {
return type + "-" + utils.hmacSHA256(String(song.id), "new-api:suno:artifact-key");
}
export function listArtifacts(task) {
if (task.status !== "SUCCESS") return [];
const artifacts = [];
for (const song of artifactData(task)) {
if (!song || !String(song.id || "").trim()) continue;
if (String(song.audio_url || "").trim()) {
artifacts.push({ key: artifactKey("audio", song), type: "audio", mimeType: "audio/mpeg" });
}
if (String(song.image_url || "").trim()) {
artifacts.push({ key: artifactKey("cover", song), type: "image" });
}
}
return artifacts;
}
export function buildContentRequest(ctx) {
const song = artifactData(ctx).find(function (item) {
if (!item || !String(item.id || "").trim()) return false;
return artifactKey("audio", item) === ctx.artifactKey || artifactKey("cover", item) === ctx.artifactKey;
});
if (!song) throw new Error("artifact_not_found");
let url = "";
if (artifactKey("audio", song) === ctx.artifactKey) url = String(song.audio_url || "").trim();
if (artifactKey("cover", song) === ctx.artifactKey) url = String(song.image_url || "").trim();
if (!url) throw new Error("artifact_not_found");
return { url: url, method: ctx.clientRequest.method, credentialless: true };
}
export function extractUsageOnComplete(task, taskResult, body) {
const values = Array.isArray(body) ? body : body && typeof body === "object" ? [body] : [];
if (values.length === 0) return {};
const music = values.some(function (item) {
return item && (trimmed(item.audio_url) || trimmed(item.video_url));
});
return { clips: values.length, action: music ? "music" : "lyrics" };
}
function escapedAttribute(value) {
return trimmed(value).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function responseContent(ctx, task) {
const declared = trimmed(ctx.upstreamModel || ctx.model).toLowerCase();
const songs = artifactData(task);
const lyrics = [];
for (const song of songs) {
if (!song) continue;
const text = trimmed(song.text);
if (!text) continue;
const title = trimmed(song.title);
lyrics.push(title ? title + "\n" + text : text);
}
const lyricsMode =
declared === "suno_lyrics" ||
(declared !== "suno_music" &&
!songs.some(function (song) {
return song && trimmed(song.audio_url);
}));
if (lyricsMode) {
return [{ type: "output_text", text: lyrics.join("\n\n") || "Lyrics generation completed.", annotations: [], logprobs: [] }];
}
const content = [{ type: "output_text", text: lyrics.join("\n\n") || "Music generation completed.", annotations: [], logprobs: [] }];
for (const song of songs) {
if (!song || !trimmed(song.audio_url)) continue;
const key = artifactKey("audio", song);
const artifact = ctx && ctx.artifacts && ctx.artifacts[key];
const url = trimmed(artifact && artifact.url);
if (!url) throw new Error("audio artifact is unavailable");
content.push({ type: "output_text", text: '<audio controls src="' + escapedAttribute(url) + '"></audio>', annotations: [], logprobs: [] });
}
return content;
}
function responseText(ctx, task) {
return responseContent(ctx, task)
.map(function (part) {
return part.text;
})
.join("\n\n");
}
export const protocols = {
openai_responses: {
decodeRequest: function (ctx) {
if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
const req = ctx.body.value;
if (!req || typeof req !== "object" || Array.isArray(req)) throw new Error("request body must be an object");
const declared = trimmed(ctx.upstreamModel || ctx.model).toLowerCase();
if (declared !== "suno_music" && declared !== "suno_lyrics") throw new Error("model must be suno_music or suno_lyrics");
if (req.input !== undefined && typeof req.input !== "string" && !Array.isArray(req.input)) throw new Error("input must be a string or array");
if (req.metadata !== undefined && (!req.metadata || typeof req.metadata !== "object" || Array.isArray(req.metadata)))
throw new Error("metadata must be an object");
const input = responsesText(req);
const requestBody = Object.assign({}, req.metadata || {});
if (declared === "suno_lyrics") {
if (!trimmed(requestBody.prompt)) requestBody.prompt = input || trimmed(req.prompt);
if (!trimmed(requestBody.prompt)) throw new Error("input is required");
return { kind: "submit", model: ctx.model, action: "LYRICS", requestBody: requestBody };
}
if (!trimmed(requestBody.gpt_description_prompt)) requestBody.gpt_description_prompt = input || trimmed(req.prompt);
if (!trimmed(requestBody.gpt_description_prompt) && !trimmed(requestBody.prompt)) throw new Error("input is required");
return { kind: "submit", model: ctx.model, action: "MUSIC", requestBody: requestBody };
},
renderEvents: function (ctx, task, previousState) {
const status = String(task.status || "UNKNOWN").toUpperCase();
const value = Number(String(task.progress || "").replace("%", ""));
const progress = Number.isFinite(value) && value >= 0 && value <= 100 ? value : null;
const state = { status: status, progress: progress };
if (status === "SUCCESS") {
const text = responseText(ctx, task);
const events = previousState && previousState.status === status ? [] : [{ type: "output", data: text }];
return { events: events, state: state, done: true };
}
if (status === "FAILURE")
return { events: [{ type: "error", code: "task_failed", message: task.fail_reason || "task failed" }], state: state, done: true };
if (previousState && previousState.status === status && previousState.progress === progress) return { events: [], state: state, done: false };
const event = { type: "progress", message: status.toLowerCase() };
if (progress !== null) event.progress = progress;
return { events: [event], state: state, done: false };
},
renderFinal: function (ctx, task) {
return { output: [{ type: "message", status: "completed", role: "assistant", content: responseContent(ctx, task) }], metadata: { vendor: "sunoapi" } };
},
},
};
function nativeTask(task) {
return {
created_at: task.created_at || 0,
updated_at: task.updated_at || 0,
task_id: task.task_id || "",
platform: task.platform || "sunoapi",
status: task.status || "",
fail_reason: task.fail_reason || "",
submit_time: task.created_at || 0,
finish_time: task.finished_at || 0,
progress: task.progress || "",
data: task.data === undefined ? null : task.data,
};
}
export const native = {
decodeSubmit: decodeNativeSubmit,
decodeBatch: decodeNativeBatch,
renderSubmit: function (ctx, task) {
const data = task.data && typeof task.data === "object" ? task.data : {};
return { code: "success", message: String(data.message || ""), data: String(task.task_id || "") };
},
renderTask: function (ctx, task) {
return { code: "success", message: "", data: nativeTask(task) };
},
renderTasks: function (ctx, tasks) {
return { code: "success", message: "", data: tasks.map(nativeTask) };
},
error: function (ctx, error) {
return { code: error.code, message: error.message, data: null };
},
};
+396
View File
@@ -0,0 +1,396 @@
export const meta = {
apiVersion: 1,
key: "vertex-ai",
name: "Google Veo (Vertex AI)",
icon: "VertexAI.Color",
description: {
en: "Google Veo video generation on Vertex AI (text-to-video and image-to-video)",
zh: "Google Veo 视频生成(文生视频、图生视频),Vertex AI 版本",
},
version: "1.0.2",
channelTypes: [41],
author: { name: "QuantumNous" },
models: ["veo-3.0-generate-001", "veo-3.0-fast-generate-001", "veo-3.1-generate-preview", "veo-3.1-fast-generate-preview"],
fetchMode: "per_task",
auth: { type: "oauth2_jwt" },
usageSchema: {
// Requested video duration in seconds. Allowed values: 4, 6, 8.
seconds: {
type: "number",
unit: "second",
description: { en: "Video generation unit price", zh: "视频生成单价" },
},
// Requested video output resolution.
resolution: {
enum: ["720p", "1080p", "4k"],
enumLabels: { "720p": { en: "720p", zh: "720p" }, "1080p": { en: "1080p", zh: "1080p" }, "4k": { en: "4k", zh: "4k" } },
description: { en: "Output video resolution", zh: "输出视频分辨率" },
},
// Whether audio is generated. Default true. Audio and muted tiers have different prices.
generate_audio: {
type: "boolean",
description: { en: "Whether audio is generated", zh: "是否生成音频" },
},
},
usageExamples: [
{ label: "8s 720p audio", facts: { seconds: 8, resolution: "720p", generate_audio: true } },
{ label: "8s 720p muted", facts: { seconds: 8, resolution: "720p", generate_audio: false } },
{ label: "8s 1080p audio", facts: { seconds: 8, resolution: "1080p", generate_audio: true } },
{ label: "8s 4k audio", facts: { seconds: 8, resolution: "4k", generate_audio: true } },
],
protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
};
function trimmed(value) {
return String(value || "").trim();
}
function responsesInput(req) {
const texts = [],
images = [];
const input = req.input;
if (typeof input === "string") texts.push(input);
else if (Array.isArray(input)) {
for (const item of input) {
if (typeof item === "string") {
texts.push(item);
continue;
}
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const content = item.content === undefined ? [item] : Array.isArray(item.content) ? item.content : [item.content];
for (const part of content) {
if (typeof part === "string") {
texts.push(part);
continue;
}
if (!part || typeof part !== "object" || Array.isArray(part)) continue;
if (["input_text", "text"].includes(part.type) && typeof part.text === "string") texts.push(part.text);
if (["input_image", "image_url"].includes(part.type)) {
let image = part.image_url;
if (image && typeof image === "object") image = image.url;
if (trimmed(image)) images.push(trimmed(image));
}
}
}
}
return {
prompt: texts
.filter(function (text) {
return trimmed(text);
})
.join("\n"),
images: images,
};
}
function validImageInput(value) {
value = trimmed(value);
if (!value) return false;
if (value.startsWith("data:")) {
const comma = value.indexOf(",");
return comma >= 0 && Boolean(value.slice(comma + 1));
}
return /^[A-Za-z0-9+/]+={0,2}$/.test(value);
}
function sizeParts(size) {
const p = String(size || "")
.toLowerCase()
.split("x");
return p.length === 2 ? [Number(p[0]), Number(p[1])] : null;
}
function resolution(size) {
const p = sizeParts(size);
if (!p) return "720p";
const m = Math.max(p[0], p[1]);
return m >= 3840 ? "4k" : m >= 1920 ? "1080p" : "720p";
}
function aspect(size) {
const p = sizeParts(size);
return !p || p[0] <= 0 || p[1] <= 0 ? "16:9" : p[1] > p[0] ? "9:16" : "16:9";
}
function duration(req) {
let n = Number(req.duration);
if (!Number.isFinite(n) || n <= 0) n = 8;
return n;
}
function region(ctx, model) {
const setting = ctx.userSetting || {};
const configured = setting.vertexRegion || setting.apiVersion || "global";
if (configured && typeof configured === "object") return configured[model] || configured.default || "global";
return String(configured || "global");
}
function apiBase(baseUrl, project, location) {
const base = String(baseUrl || "").replace(/\/$/, "");
if (base) return base + (base.endsWith("/v1") ? "" : "/v1") + "/projects/" + project + "/locations/" + location;
return "https://" + (location === "global" ? "" : location + "-") + "aiplatform.googleapis.com/v1/projects/" + project + "/locations/" + location;
}
function modelURL(ctx, project, location, model, action) {
return apiBase(ctx.baseUrl, project, location) + "/publishers/google/models/" + model + ":" + action;
}
function decodeTaskId(value) {
return utils.base64URLDecode(value);
}
function operationPart(name, marker) {
const start = name.indexOf(marker);
if (start < 0) return "";
return name.slice(start + marker.length).split("/")[0];
}
function dataVideo(response) {
const videos = response.videos || [];
const first = videos[0] || {};
const data = first.bytesBase64Encoded || response.bytesBase64Encoded || response.video || "";
if (!data || String(data).startsWith("data:") || String(data).startsWith("http")) return data;
const enc = first.mimeType || first.encoding || response.encoding || "mp4";
return "data:" + (String(enc).includes("/") ? enc : "video/" + enc) + ";base64," + data;
}
export function buildSubmitRequest(ctx) {
if (ctx.authError) throw new Error(ctx.authError);
const req = ctx.requestBody || {},
metadata = Object.assign({}, req.metadata || {}),
model = ctx.upstreamModel || "veo-3.0-generate-001";
if (Number(req.duration) > 0) metadata.durationSeconds = Number(req.duration);
if (!metadata.resolution && req.size) metadata.resolution = resolution(req.size);
if (!metadata.aspectRatio && req.size) metadata.aspectRatio = aspect(req.size);
if (metadata.resolution) metadata.resolution = String(metadata.resolution).toLowerCase();
metadata.sampleCount = 1;
const instance = { prompt: req.prompt };
const image = (req.images || [])[0];
if (image && typeof image === "object" && !Array.isArray(image) && image.__fileRef) {
let mime = String(image.mimeType || "").toLowerCase();
if (mime !== "image/jpeg" && mime !== "image/png") {
const files = ctx.files || [];
for (const file of files) {
if (file.ref === image.__fileRef || file.field === "input_reference") {
mime = String(file.mimeType || "").toLowerCase();
break;
}
}
}
if (mime !== "image/jpeg" && mime !== "image/png") throw new Error("input image must be image/jpeg or image/png");
instance.image = { bytesBase64Encoded: image, mimeType: mime };
} else if (image) {
const text = String(image);
const comma = text.indexOf(",");
let mime = "";
let data = text;
if (text.startsWith("data:")) {
mime = text.slice(5, comma).split(";")[0].toLowerCase();
data = text.slice(comma + 1);
} else if (text.startsWith("iVBORw0KGgo")) mime = "image/png";
else if (text.startsWith("/9j/")) mime = "image/jpeg";
if (mime !== "image/jpeg" && mime !== "image/png") throw new Error("input image must be image/jpeg or image/png");
instance.image = { bytesBase64Encoded: data, mimeType: mime };
}
return {
url: modelURL(ctx, ctx.auth.projectId, region(ctx, model), model, "predictLongRunning"),
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: ctx.authHeader, "x-goog-user-project": ctx.auth.projectId },
body: { instances: [instance], parameters: metadata },
action: image ? "image_to_video" : "text_to_video",
};
}
export function parseSubmitResponse(ctx, resp) {
const body = resp.body || {};
if (!String(body.name || "").trim()) throw new Error("missing operation name");
return { taskId: utils.base64URL(body.name), taskData: body };
}
export function extractUsage(ctx) {
const req = ctx.requestBody || {};
const metadata = req.metadata || {};
return {
seconds: duration(req),
resolution: String(metadata.resolution || resolution(req.size) || "720p").toLowerCase(),
generate_audio: metadata.generateAudio !== false,
};
}
export function extractUsageOnComplete() {
return null;
}
export function buildQueryRequest(ctx) {
const name = decodeTaskId(ctx.taskId),
project = operationPart(name, "projects/"),
location = operationPart(name, "locations/"),
model = operationPart(name, "models/");
if (!project || !model) throw new Error("cannot extract project or model from operation name");
return {
url: modelURL(ctx, project, location || "us-central1", model, "fetchPredictOperation"),
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: ctx.authHeader, "x-goog-user-project": ctx.auth.projectId },
body: { operationName: name },
};
}
export function parseTaskResult(ctx, body) {
if (body.error && body.error.message) return { status: "FAILURE", progress: "100%", reason: body.error.message };
// Google long-running operations omit `done` (proto3 default) while still
// running, so a missing key means in-progress; only a non-operation shape is
// unrecognized.
if (!body || typeof body !== "object" || !String(body.name || "").trim()) return { status: "UNKNOWN", reason: "unrecognized operation state" };
if (body.done !== true) return { status: "IN_PROGRESS", progress: "50%" };
const url = dataVideo(body.response || {});
return { status: "SUCCESS", progress: "100%", url: url, remoteUrl: url };
}
export function listArtifacts() {
return [];
}
export function buildContentRequest() {
throw new Error("artifact_not_found");
}
function completionMessage(ctx, task) {
const request = (ctx && ctx.requestBody) || {};
const model = trimmed(request.model) || trimmed((task.properties || {}).origin_model_name) || "veo-3.0-generate-001";
const status = String(task.status || "SUCCESS").toUpperCase();
return (
"Video generation for " +
model +
" completed (" +
duration(request) +
" seconds, status " +
status +
"). Retrieve the video through the native /v1/videos task flow."
);
}
export const protocols = {
openai_responses: {
decodeRequest: function (ctx) {
if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
const req = ctx.body.value;
if (!req || typeof req !== "object" || Array.isArray(req)) throw new Error("request body must be an object");
const model = trimmed(req.model);
if (!model) throw new Error("model is required");
if (req.input !== undefined && typeof req.input !== "string" && !Array.isArray(req.input)) throw new Error("input must be a string or array");
if (req.images !== undefined && !Array.isArray(req.images)) throw new Error("images must be an array");
if (req.metadata !== undefined && (!req.metadata || typeof req.metadata !== "object" || Array.isArray(req.metadata)))
throw new Error("metadata must be an object");
const input = responsesInput(req);
const prompt = input.prompt || trimmed(req.prompt);
const images = [];
for (const image of [req.image, req.input_reference].concat(req.images || [], input.images)) {
if (trimmed(image) && !images.includes(trimmed(image))) images.push(trimmed(image));
}
if (!prompt && images.length === 0) throw new Error("input is required");
if (images.length && !validImageInput(images[0])) throw new Error("input image must be a data URL or base64 value");
const metadata = Object.assign({}, req.metadata || {});
if (Object.prototype.hasOwnProperty.call(req, "resolution")) metadata.resolution = req.resolution;
const requestBody = { model: model, prompt: prompt, metadata: metadata };
if (images.length) requestBody.images = images;
if (Object.prototype.hasOwnProperty.call(req, "seconds")) requestBody.duration = req.seconds;
else if (Object.prototype.hasOwnProperty.call(req, "duration")) requestBody.duration = req.duration;
if (Object.prototype.hasOwnProperty.call(req, "size")) requestBody.size = req.size;
return { kind: "submit", model: model, action: images.length ? "image_to_video" : "text_to_video", requestBody: requestBody };
},
renderEvents: function (ctx, task, previousState) {
const status = String(task.status || "UNKNOWN").toUpperCase();
const value = Number(String(task.progress || "").replace("%", ""));
const progress = Number.isFinite(value) && value >= 0 && value <= 100 ? value : null;
const state = { status: status, progress: progress };
if (status === "SUCCESS") {
const events = previousState && previousState.status === status ? [] : [{ type: "output", data: completionMessage(ctx, task) }];
return { events: events, state: state, done: true };
}
if (status === "FAILURE")
return { events: [{ type: "error", code: "task_failed", message: task.fail_reason || "task failed" }], state: state, done: true };
if (previousState && previousState.status === status && previousState.progress === progress) return { events: [], state: state, done: false };
const event = { type: "progress", message: status.toLowerCase() };
if (progress !== null) event.progress = progress;
return { events: [event], state: state, done: false };
},
renderFinal: function (ctx, task) {
return {
output: [
{
type: "message",
status: "completed",
role: "assistant",
content: [{ type: "output_text", text: completionMessage(ctx, task), annotations: [], logprobs: [] }],
},
],
metadata: { vendor: "vertex", artifact_mode: "native_videos" },
};
},
},
};
const legacyRenderers = {
openai_video: function (task) {
const model = (task.properties || {}).origin_model_name || "veo-3.0-generate-001";
const statuses = { NOT_START: "queued", SUBMITTED: "queued", QUEUED: "queued", IN_PROGRESS: "in_progress", SUCCESS: "completed", FAILURE: "failed" };
const out = {
id: task.task_id,
object: "video",
model,
status: statuses[task.status] || "unknown",
progress: Number(String(task.progress || "0").replace("%", "")),
created_at: task.created_at,
};
if (Number(task.updated_at) > 0) out.completed_at = Number(task.updated_at);
return out;
},
};
protocols.openai_video = {
decodeRequest: function (ctx) {
if (!ctx.body || (ctx.body.kind !== "json" && ctx.body.kind !== "multipart")) throw new Error("JSON or multipart body required");
let req;
let hasInputReferenceFile = false;
if (ctx.body.kind === "json") {
if (!ctx.body.value || Array.isArray(ctx.body.value)) throw new Error("JSON object required");
req = Object.assign({}, ctx.body.value);
} else {
const first = function (name) {
const values = (ctx.body.fields || {})[name] || [];
if (values.length > 1) throw new Error(name + " must be provided once");
return values[0];
};
req = {};
const fields = ctx.body.fields || {};
for (const name of Object.keys(fields)) {
req[name] = first(name);
}
for (const file of ctx.body.files || []) {
if (file.field !== "input_reference") throw new Error("unexpected file field: " + file.field);
if (hasInputReferenceFile) throw new Error("input_reference must be provided once");
const mime = String(file.mimeType || "").toLowerCase();
if (mime !== "image/jpeg" && mime !== "image/png") throw new Error("input_reference must be image/jpeg or image/png");
hasInputReferenceFile = true;
}
if (req.metadata !== undefined) {
let parsed;
try {
parsed = JSON.parse(req.metadata);
} catch {
throw new Error("metadata must be a JSON object string");
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("metadata must be a JSON object string");
req.metadata = parsed;
}
if (req.seconds !== undefined) req.seconds = Number(req.seconds);
else if (req.duration !== undefined) req.seconds = Number(req.duration);
}
const seconds = req.seconds === undefined ? req.duration : req.seconds;
if (seconds !== undefined) {
const n = Number(seconds);
if (n !== 4 && n !== 6 && n !== 8) throw new Error("seconds must be one of 4, 6, or 8");
req.duration = n;
}
const providedResolution = req.resolution !== undefined ? req.resolution : req.metadata && req.metadata.resolution;
if (providedResolution !== undefined && providedResolution !== "") {
const value = String(providedResolution).toLowerCase();
if (value !== "720p" && value !== "1080p" && value !== "4k") throw new Error("resolution must be one of 720p, 1080p, or 4k");
}
if (hasInputReferenceFile) {
req.images = [{ __fileRef: "request_file:input_reference", encoding: "base64", maxBytes: 20971520 }];
}
return {
kind: "submit",
model: ctx.model,
action: hasInputReferenceFile || req.input_reference || req.image ? "image_to_video" : "text_to_video",
requestBody: Object.assign({}, req, { model: ctx.model }),
};
},
render: function (ctx, task) {
return legacyRenderers.openai_video(task);
},
};
+452
View File
@@ -0,0 +1,452 @@
export const meta = {
apiVersion: 1,
key: "vidu",
name: "Vidu",
icon: "Vidu.Color",
description: {
en: "Shengshu Vidu video generation (text-to-video, image-to-video, first-and-last-frame, and reference-to-video)",
zh: "生数 Vidu 视频生成(文生视频、图生视频、首尾帧、参考生视频)",
},
version: "1.0.2",
author: { name: "QuantumNous" },
channelTypes: [52],
models: ["viduq2", "viduq1", "vidu2.0", "vidu1.5"],
fetchMode: "per_task",
usageSchema: {
// estimated/actual Vidu credits
credits: {
type: "number",
unit: "credit",
description: { en: "Vidu credit unit price", zh: "Vidu 积分单价" },
},
// Requested video duration in seconds.
duration: {
type: "number",
unit: "second",
description: { en: "Video generation unit price", zh: "视频生成单价" },
},
// Requested output video resolution.
resolution: {
enum: ["360p", "540p", "720p", "1080p"],
enumLabels: {
"360p": { en: "360p", zh: "360p" },
"540p": { en: "540p", zh: "540p" },
"720p": { en: "720p", zh: "720p" },
"1080p": { en: "1080p", zh: "1080p" },
},
description: { en: "Output video resolution", zh: "输出视频分辨率" },
},
},
// credits is 0 in examples because this plugin does not estimate vendor credits;
// schema completeness requires the key. Actual credits arrive from upstream.
usageExamples: [
{ label: "q2 5s 720p", facts: { credits: 0, duration: 5, resolution: "720p" } },
{ label: "q1 5s 1080p", facts: { credits: 0, duration: 5, resolution: "1080p" } },
{ label: "2.0 4s 360p", facts: { credits: 0, duration: 4, resolution: "360p" } },
{ label: "2.0 4s 720p", facts: { credits: 0, duration: 4, resolution: "720p" } },
{ label: "2.0 8s 720p", facts: { credits: 0, duration: 8, resolution: "720p" } },
],
protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
};
const RESOLUTIONS = ["360p", "540p", "720p", "1080p"];
function trimmed(value) {
return String(value || "").trim();
}
function isQ2Model(model) {
return String(model || "").indexOf("viduq2") === 0;
}
function defaultDuration(model) {
if (model === "vidu2.0") return 4;
return 5;
}
function defaultResolution(model) {
if (isQ2Model(model)) return "720p";
if (model === "vidu2.0") return "360p";
return "1080p";
}
function normalizeResolution(value, model) {
if (model === "viduq1") return "1080p";
const raw = trimmed(value).toLowerCase();
if (RESOLUTIONS.indexOf(raw) >= 0) return raw;
const parts = raw.replace("*", "x").split("x");
if (parts.length === 2) {
const width = Number(parts[0]);
const height = Number(parts[1]);
if (width > 0 && height > 0) {
const max = Math.max(width, height);
if (max >= 1920) return "1080p";
if (max >= 1280) return "720p";
if (max >= 960) return "540p";
return "360p";
}
}
return defaultResolution(model);
}
function outboundDuration(req, model) {
const n = Number(req && req.duration);
if (Number.isFinite(n) && n > 0) return n;
return defaultDuration(model);
}
function outboundResolution(req, model) {
if (req && req.resolution) return normalizeResolution(req.resolution, model);
const metadata = (req && req.metadata) || {};
if (metadata.resolution) return normalizeResolution(metadata.resolution, model);
if (req && req.size) return normalizeResolution(req.size, model);
return defaultResolution(model);
}
function hasViduImages(req, hasInputReferenceFile) {
if (hasInputReferenceFile) return true;
if (Array.isArray(req && req.images) && req.images.length) return true;
return Boolean(trimmed(req && req.input_reference) || trimmed(req && req.image));
}
function validateViduCombo(model, duration, resolution, hasImages) {
if (model === "vidu2.0" && !hasImages) {
throw new Error("vidu2.0 does not support text-to-video");
}
if (model === "viduq1") {
if (duration !== undefined && Number(duration) !== 5) throw new Error("viduq1 duration must be 5");
return;
}
if (model === "vidu2.0") {
const n = duration === undefined ? 4 : Number(duration);
if (n === 4) {
if (["360p", "720p", "1080p"].indexOf(resolution) < 0) throw new Error("vidu2.0 duration 4 only allows resolution 360p, 720p, or 1080p");
return;
}
if (n === 8) {
if (resolution !== "720p") throw new Error("vidu2.0 duration 8 only allows resolution 720p");
return;
}
throw new Error("vidu2.0 duration must be 4 or 8");
}
if (isQ2Model(model)) {
if (duration === undefined) return;
const n = Number(duration);
if (!Number.isInteger(n) || n < 1 || n > 10) throw new Error("viduq2 duration must be between 1 and 10");
return;
}
if (duration !== undefined) {
const n = Number(duration);
if (!Number.isInteger(n) || n <= 0 || n > 3600) throw new Error("seconds must be between 1 and 3600");
}
}
function filePlaceholder(image) {
if (!image || typeof image !== "object" || Array.isArray(image) || !image.__fileRef) return image;
const placeholder = { __fileRef: image.__fileRef, encoding: image.encoding };
if (image.mimeType) placeholder.mimeType = image.mimeType;
if (image.maxBytes !== undefined && image.maxBytes !== null) placeholder.maxBytes = image.maxBytes;
return placeholder;
}
function responsesInput(req) {
const texts = [],
images = [];
const input = req.input;
if (typeof input === "string") texts.push(input);
else if (Array.isArray(input)) {
for (const item of input) {
if (typeof item === "string") {
texts.push(item);
continue;
}
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const content = item.content === undefined ? [item] : Array.isArray(item.content) ? item.content : [item.content];
for (const part of content) {
if (typeof part === "string") {
texts.push(part);
continue;
}
if (!part || typeof part !== "object" || Array.isArray(part)) continue;
if (["input_text", "text"].includes(part.type) && typeof part.text === "string") texts.push(part.text);
if (["input_image", "image_url"].includes(part.type)) {
let image = part.image_url;
if (image && typeof image === "object") image = image.url;
if (trimmed(image)) images.push(trimmed(image));
}
}
}
}
return {
prompt: texts
.filter(function (text) {
return trimmed(text);
})
.join("\n"),
images: images,
};
}
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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
return '<video controls src="' + escaped + '"></video>';
}
function actionFor(req) {
if (req.metadata && req.metadata.action) {
const aliases = {
generate: "image_to_video",
textGenerate: "text_to_video",
firstTailGenerate: "first_tail_to_video",
referenceGenerate: "reference_to_video",
remixGenerate: "remix",
};
return aliases[req.metadata.action] || req.metadata.action;
}
if (!req.images || req.images.length === 0) return "text_to_video";
if (req.images.length === 2) return "first_tail_to_video";
if (req.images.length > 2) return "reference_to_video";
return "image_to_video";
}
function pathFor(action) {
if (action === "image_to_video") return "/img2video";
if (action === "first_tail_to_video") return "/start-end2video";
if (action === "reference_to_video") return "/reference2video";
return "/text2video";
}
export function buildSubmitRequest(ctx) {
const req = ctx.requestBody;
const action = actionFor(req);
const metadata = req.metadata || {};
let model = ctx.upstreamModel || "viduq1";
if (action === "reference_to_video" && model.includes("viduq2")) model = "viduq2";
const images = Array.isArray(req.images) ? req.images.map(filePlaceholder) : null;
const body = Object.assign(
{
model: model,
images: images,
prompt: req.prompt || null,
duration: outboundDuration(req, model),
resolution: outboundResolution(req, model),
movement_amplitude: "auto",
},
metadata
);
delete body.action;
if (!body.prompt) delete body.prompt;
if (!body.bgm) delete body.bgm;
if (Array.isArray(body.images)) body.images = body.images.map(filePlaceholder);
return {
url: ctx.baseUrl + "/ent/v2" + pathFor(action),
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: "Token " + ctx.apiKey },
body: body,
action: action,
};
}
export function parseSubmitResponse(ctx, resp) {
if (!resp.body || resp.body.state === "failed") throw new Error("task failed");
if (!resp.body.task_id) throw new Error("missing task_id");
return { taskId: resp.body.task_id, taskData: resp.body };
}
export function extractUsage(ctx) {
if (ctx.usagePurpose === "billing_ratios") return null;
const req = ctx.requestBody || {};
const model = ctx.upstreamModel || req.model;
return { duration: outboundDuration(req, model), resolution: outboundResolution(req, model) };
}
export function buildQueryRequest(ctx) {
return {
url: ctx.baseUrl + "/ent/v2/tasks/" + ctx.taskId + "/creations",
method: "GET",
headers: { Accept: "application/json", Authorization: "Token " + ctx.apiKey },
};
}
export function parseTaskResult(ctx, body) {
const statuses = { created: "SUBMITTED", queueing: "SUBMITTED", processing: "IN_PROGRESS", success: "SUCCESS", failed: "FAILURE" };
const status = statuses[body.state];
if (!status) return { status: "UNKNOWN", reason: "unknown task state: " + String(body.state || "") };
const url = body.creations && body.creations.length ? body.creations[0].url || "" : "";
const result = { status: status, reason: body.state === "failed" ? body.err_code || "" : "" };
if (url) result.url = url;
return result;
}
function artifactData(ctx) {
const data = (ctx && ctx.data) || {};
if (data.data && typeof data.data === "object" && data.data.task_id && Object.prototype.hasOwnProperty.call(data.data, "data")) return data.data.data || {};
return data;
}
function artifactVideoURL(ctx) {
const creations = artifactData(ctx).creations;
return Array.isArray(creations) && creations.length ? String(creations[0].url || "").trim() : "";
}
export function listArtifacts(task) {
return task.status === "SUCCESS" && artifactVideoURL(task) ? [{ key: "video", type: "video" }] : [];
}
export function buildContentRequest(ctx) {
if (ctx.artifactKey !== "video") throw new Error("artifact_not_found");
const url = artifactVideoURL(ctx);
if (!url) throw new Error("artifact_not_found");
return { url: url, method: ctx.clientRequest.method, credentialless: true };
}
export function extractUsageOnComplete(_task, _taskResult, body) {
if (!body || !Object.prototype.hasOwnProperty.call(body, "credits") || body.credits === undefined || body.credits === null) return null;
const credits = Number(body.credits);
if (!Number.isFinite(credits)) return null;
return { credits: credits };
}
export const protocols = {
openai_responses: {
decodeRequest: function (ctx) {
if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
const req = ctx.body.value;
if (!req || typeof req !== "object" || Array.isArray(req)) throw new Error("request body must be an object");
const model = trimmed(req.model);
if (!model) throw new Error("model is required");
if (req.input !== undefined && typeof req.input !== "string" && !Array.isArray(req.input)) throw new Error("input must be a string or array");
if (req.images !== undefined && !Array.isArray(req.images)) throw new Error("images must be an array");
if (req.metadata !== undefined && (!req.metadata || typeof req.metadata !== "object" || Array.isArray(req.metadata)))
throw new Error("metadata must be an object");
const input = responsesInput(req);
const prompt = input.prompt || trimmed(req.prompt);
const images = [];
for (const image of [req.image, req.input_reference].concat(req.images || [], input.images)) {
if (trimmed(image) && !images.includes(trimmed(image))) images.push(trimmed(image));
}
if (!prompt && images.length === 0) throw new Error("input is required");
const requestBody = { model: model, prompt: prompt };
if (images.length) requestBody.images = images;
if (Object.prototype.hasOwnProperty.call(req, "seconds")) requestBody.duration = req.seconds;
else if (Object.prototype.hasOwnProperty.call(req, "duration")) requestBody.duration = req.duration;
if (Object.prototype.hasOwnProperty.call(req, "size")) requestBody.size = req.size;
if (Object.prototype.hasOwnProperty.call(req, "metadata")) requestBody.metadata = req.metadata;
const duration = requestBody.duration === undefined ? undefined : Number(requestBody.duration);
validateViduCombo(model, duration, outboundResolution(requestBody, model), images.length > 0);
return { kind: "submit", model: model, action: actionFor(requestBody), requestBody: requestBody };
},
renderEvents: function (ctx, task, previousState) {
const status = String(task.status || "UNKNOWN").toUpperCase();
const value = Number(String(task.progress || "").replace("%", ""));
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 events = previousState && previousState.status === status ? [] : [{ type: "output", data: text }];
return { events: events, state: state, done: true };
}
if (status === "FAILURE")
return { events: [{ type: "error", code: "task_failed", message: task.fail_reason || "task failed" }], state: state, done: true };
if (previousState && previousState.status === status && previousState.progress === progress) return { events: [], state: state, done: false };
const event = { type: "progress", message: status.toLowerCase() };
if (progress !== null) event.progress = progress;
return { events: [event], state: state, done: false };
},
renderFinal: function (ctx, _task) {
return {
output: [
{
type: "message",
status: "completed",
role: "assistant",
content: [{ type: "output_text", text: responsesVideoText(ctx), annotations: [], logprobs: [] }],
},
],
metadata: { vendor: "vidu" },
};
},
},
};
const legacyRenderers = {
openai_video: function (task) {
const statusMap = { NOT_START: "queued", SUBMITTED: "queued", QUEUED: "queued", IN_PROGRESS: "in_progress", SUCCESS: "completed", FAILURE: "failed" };
const output = {
id: task.task_id,
object: "video",
model: "",
status: statusMap[task.status] || "unknown",
progress: Number(String(task.progress || "0").replace("%", "")),
created_at: task.created_at,
};
if (task.updated_at) output.completed_at = task.updated_at;
if (task.data && task.data.state === "failed" && task.data.err_code) output.error = { message: task.data.err_code, code: task.data.err_code };
return output;
},
};
protocols.openai_video = {
decodeRequest: function (ctx) {
if (!ctx.body || (ctx.body.kind !== "json" && ctx.body.kind !== "multipart")) throw new Error("JSON or multipart body required");
let req;
let hasInputReferenceFile = false;
if (ctx.body.kind === "json") {
if (!ctx.body.value || Array.isArray(ctx.body.value)) throw new Error("JSON object required");
req = Object.assign({}, ctx.body.value);
} else {
const first = function (name) {
const values = (ctx.body.fields || {})[name] || [];
if (values.length > 1) throw new Error(name + " must be provided once");
return values[0];
};
req = {};
const fields = ctx.body.fields || {};
for (const name of Object.keys(fields)) {
req[name] = first(name);
}
for (const file of ctx.body.files || []) {
if (file.field !== "input_reference") throw new Error("unexpected file field: " + file.field);
if (hasInputReferenceFile) throw new Error("input_reference must be provided once");
hasInputReferenceFile = true;
}
if (req.metadata !== undefined) {
let parsed;
try {
parsed = JSON.parse(req.metadata);
} catch (e) {
throw new Error("metadata must be a JSON object string");
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("metadata must be a JSON object string");
req.metadata = parsed;
}
if (req.seconds !== undefined) req.seconds = Number(req.seconds);
else if (req.duration !== undefined) req.seconds = Number(req.duration);
}
const model = ctx.upstreamModel || ctx.model || req.model;
const seconds = req.seconds === undefined ? req.duration : req.seconds;
if (seconds !== undefined) req.duration = Number(seconds);
else req.duration = defaultDuration(model);
if (hasInputReferenceFile) {
req.images = [{ __fileRef: "request_file:input_reference", encoding: "dataUrl", maxBytes: 15728640 }];
} else {
const image = trimmed(req.input_reference || req.image);
if (image && (!Array.isArray(req.images) || req.images.length === 0)) req.images = [image];
}
req.resolution = outboundResolution(req, model);
const hasImages = hasViduImages(req, hasInputReferenceFile);
validateViduCombo(model, req.duration, req.resolution, hasImages);
return {
kind: "submit",
model: ctx.model,
action: actionFor(req),
requestBody: Object.assign({}, req, { model: ctx.model }),
};
},
render: function (ctx, task) {
return legacyRenderers.openai_video(task);
},
};
+61
View File
@@ -0,0 +1,61 @@
package plugins_test
import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/pkg/jsplugin"
builtinplugins "github.com/QuantumNous/new-api/plugins"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Google long-running operations serialize proto3 defaults by omission: a
// still-running operation has no `done` key at all. Treating that as
// unrecognized would count every normal poll as a failure and fail the task
// at the poll-failure threshold while the video is still rendering.
func TestVeoParseTaskResultTreatsMissingDoneAsInProgress(t *testing.T) {
cases := []struct {
name string
body map[string]any
wantStatus string
}{
{
name: "running operation omits done",
body: map[string]any{"name": "operations/abc", "metadata": map[string]any{"@type": "x"}},
wantStatus: "IN_PROGRESS",
},
{
name: "explicit done false",
body: map[string]any{"name": "operations/abc", "done": false},
wantStatus: "IN_PROGRESS",
},
{
name: "body without operation name is unrecognized",
body: map[string]any{"foo": "bar"},
wantStatus: "UNKNOWN",
},
{
name: "operation error is failure",
body: map[string]any{"name": "operations/abc", "done": true, "error": map[string]any{"message": "quota exceeded"}},
wantStatus: "FAILURE",
},
}
for _, key := range []string{"google", "vertex-ai"} {
source, err := builtinplugins.Source(key)
require.NoError(t, err)
plugin, err := jsplugin.NewRegistry().RegisterFactory(source, jsplugin.Options{Key: key})
require.NoError(t, err)
for _, tc := range cases {
t.Run(key+"/"+tc.name, func(t *testing.T) {
value, err := plugin.Engine.Call(t.Context(), "parseTaskResult", map[string]any{}, tc.body)
require.NoError(t, err)
encoded, err := common.Marshal(value)
require.NoError(t, err)
var result map[string]any
require.NoError(t, common.Unmarshal(encoded, &result))
assert.Equal(t, tc.wantStatus, result["status"])
})
}
}
}
+203
View File
@@ -0,0 +1,203 @@
package plugins_test
import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/pkg/jsplugin"
builtinplugins "github.com/QuantumNous/new-api/plugins"
"github.com/QuantumNous/new-api/relay"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestVertexAIResponsesProtocol(t *testing.T) {
source, err := builtinplugins.Source("vertex-ai")
require.NoError(t, err)
registry := jsplugin.NewRegistry()
plugin, err := registry.RegisterFactory(source, jsplugin.Options{Key: "vertex-ai"})
require.NoError(t, err)
t.Run("claims every 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("shares models with Gemini without losing either provider", func(t *testing.T) {
candidates := jsplugin.DefaultRegistry.Generation().LookupEndpointCandidates("POST", "/v1/responses", "veo-3.0-generate-001")
require.Len(t, candidates, 2)
assert.Equal(t, "google", candidates[0].Plugin.Meta.Key)
assert.Equal(t, "vertex-ai", candidates[1].Plugin.Meta.Key)
request := map[string]any{"model": "veo-3.0-generate-001", "body": map[string]any{"kind": "json", "value": map[string]any{
"model": "veo-3.0-generate-001", "input": "waves", "seconds": 8, "size": "1280x720",
}}}
first, callErr := candidates[0].Plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "decodeRequest"}, request)
require.NoError(t, callErr)
second, callErr := candidates[1].Plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "decodeRequest"}, request)
require.NoError(t, callErr)
assert.Equal(t, decodePluginValue(t, first), decodePluginValue(t, second))
})
t.Run("declares documented usage facts", func(t *testing.T) {
require.Len(t, plugin.Meta.UsageSchema, 3)
for _, key := range []string{"seconds", "resolution", "generate_audio"} {
schema, exists := plugin.Meta.UsageSchema[key]
require.True(t, exists, key)
assert.NotEmpty(t, schema.Description, key)
}
assert.Equal(t, []string{"720p", "1080p", "4k"}, plugin.Meta.UsageSchema["resolution"].Enum)
assert.Equal(t, "boolean", plugin.Meta.UsageSchema["generate_audio"].Type)
})
callProtocol := func(t *testing.T, hook string, args ...any) any {
t.Helper()
value, callErr := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", hook}, args...)
require.NoError(t, callErr)
return value
}
decodeMap := func(t *testing.T, value any) map[string]any {
t.Helper()
encoded, marshalErr := common.Marshal(value)
require.NoError(t, marshalErr)
var decoded map[string]any
require.NoError(t, common.Unmarshal(encoded, &decoded))
return decoded
}
requestBody := map[string]any{
"model": "veo-3.1-fast-generate-preview",
"input": []any{map[string]any{"role": "user", "content": []any{
map[string]any{"type": "input_text", "text": "animate this frame"},
map[string]any{"type": "input_image", "image_url": "data:image/png;base64,aGVsbG8="},
}}},
"seconds": 8,
"size": "1920x1080",
"resolution": "1080P",
}
t.Run("parses Responses input", func(t *testing.T) {
resolved := decodeMap(t, callProtocol(t, "decodeRequest", map[string]any{"model": requestBody["model"], "body": map[string]any{"kind": "json", "value": requestBody}}))
assert.Equal(t, "veo-3.1-fast-generate-preview", resolved["model"])
assert.Equal(t, "image_to_video", resolved["action"])
assert.Equal(t, map[string]any{
"model": "veo-3.1-fast-generate-preview",
"prompt": "animate this frame",
"images": []any{"data:image/png;base64,aGVsbG8="},
"duration": float64(8),
"size": "1920x1080",
"metadata": map[string]any{"resolution": "1080P"},
}, resolved["requestBody"])
})
t.Run("rejects malformed input", func(t *testing.T) {
_, callErr := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "decodeRequest"}, map[string]any{
"model": "veo-3.0-generate-001", "body": map[string]any{"kind": "json", "value": map[string]any{"model": "veo-3.0-generate-001", "input": map[string]any{"text": "bad"}}},
})
require.ErrorContains(t, callErr, "input must be a string or array")
_, callErr = plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "decodeRequest"}, map[string]any{
"model": "veo-3.0-generate-001", "body": map[string]any{"kind": "json", "value": map[string]any{"model": "veo-3.0-generate-001", "input": []any{map[string]any{"type": "input_image", "image_url": "https://example.com/frame.png"}}}},
})
require.ErrorContains(t, callErr, "input image must be a data URL or base64 value")
})
t.Run("extracts schema-declared usage", func(t *testing.T) {
value, callErr := plugin.Engine.Call(t.Context(), "extractUsage", map[string]any{
"requestBody": map[string]any{"duration": 8, "size": "1920x1080", "metadata": map[string]any{}},
"usagePurpose": "facts",
})
require.NoError(t, callErr)
assert.Equal(t, map[string]any{"seconds": int64(8), "resolution": "1080p", "generate_audio": true}, value)
value, callErr = plugin.Engine.Call(t.Context(), "extractUsageOnComplete", nil, map[string]any{}, map[string]any{
"response": map[string]any{"videos": []any{map[string]any{"durationSeconds": 7, "resolution": "4K"}}},
})
require.NoError(t, callErr)
assert.Nil(t, value)
})
protocolContext := map[string]any{
"requestBody": map[string]any{"model": "veo-3.1-fast-generate-preview", "duration": 8},
"stream": true,
}
successTask := map[string]any{
"task_id": "task-public", "status": "SUCCESS", "progress": "100%", "created_at": 10, "updated_at": 20,
"data": map[string]any{"url": "data:video/mp4;base64,MUST_NOT_LEAK"},
}
t.Run("renders stream state transitions", func(t *testing.T) {
progressValue := callProtocol(t, "renderEvents", protocolContext, map[string]any{"status": "IN_PROGRESS", "progress": "42%"})
progress, decodeErr := relay.DecodePluginProtocolEventResult(progressValue, relay.DefaultPluginProtocolLimits())
require.NoError(t, decodeErr)
require.Len(t, progress.Events, 1)
require.NotNil(t, progress.Events[0].Progress)
assert.Equal(t, float64(42), *progress.Events[0].Progress)
duplicateValue := callProtocol(t, "renderEvents", protocolContext, map[string]any{"status": "IN_PROGRESS", "progress": "42%"}, map[string]any{"status": "IN_PROGRESS", "progress": float64(42)})
duplicate, decodeErr := relay.DecodePluginProtocolEventResult(duplicateValue, relay.DefaultPluginProtocolLimits())
require.NoError(t, decodeErr)
assert.Empty(t, duplicate.Events)
assert.False(t, duplicate.Done)
failureValue := callProtocol(t, "renderEvents", protocolContext, map[string]any{"status": "FAILURE", "fail_reason": "blocked"})
failure, decodeErr := relay.DecodePluginProtocolEventResult(failureValue, relay.DefaultPluginProtocolLimits())
require.NoError(t, decodeErr)
require.Len(t, failure.Events, 1)
assert.Equal(t, "error", failure.Events[0].Type)
assert.True(t, failure.Done)
successValue := callProtocol(t, "renderEvents", protocolContext, successTask)
success, decodeErr := relay.DecodePluginProtocolEventResult(successValue, relay.DefaultPluginProtocolLimits())
require.NoError(t, decodeErr)
require.Len(t, success.Events, 1)
assert.True(t, success.Done)
var text string
require.NoError(t, common.Unmarshal(success.Events[0].Data, &text))
assert.Contains(t, text, "veo-3.1-fast-generate-preview")
assert.Contains(t, text, "8 seconds")
assert.Contains(t, text, "/v1/videos")
assert.NotContains(t, text, "MUST_NOT_LEAK")
assert.NotContains(t, text, "<video")
})
t.Run("renders a degraded final response without an artifact", func(t *testing.T) {
value := callProtocol(t, "renderFinal", protocolContext, successTask)
machine := relay.NewPluginResponsesMachine("task-public", "veo-3.1-fast-generate-preview", 10, relay.DefaultPluginProtocolLimits())
response, finalErr := machine.FinalResponse(value, "SUCCESS")
require.NoError(t, finalErr)
assert.Equal(t, "completed", response["status"])
output, ok := response["output"].([]any)
require.True(t, ok)
require.Len(t, output, 1)
message, ok := output[0].(map[string]any)
require.True(t, ok)
content, ok := message["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.Contains(t, text, "/v1/videos")
assert.NotContains(t, text, "MUST_NOT_LEAK")
assert.NotContains(t, text, "<video")
metadata, ok := response["metadata"].(map[string]string)
require.True(t, ok)
assert.Equal(t, "vertex", metadata["vendor"])
assert.Equal(t, "native_videos", metadata["artifact_mode"])
})
}
func decodePluginValue(t *testing.T, value any) map[string]any {
t.Helper()
encoded, err := common.Marshal(value)
require.NoError(t, err)
var decoded map[string]any
require.NoError(t, common.Unmarshal(encoded, &decoded))
return decoded
}
@@ -0,0 +1,244 @@
package plugins_test
import (
"sort"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/pkg/jsplugin"
builtinplugins "github.com/QuantumNous/new-api/plugins"
"github.com/QuantumNous/new-api/relay"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type videoResponsesTestCase struct {
pluginKey string
model string
requestBody map[string]any
wantAction string
wantRequest map[string]any
wantUsageKeys []string
wantSubmitUsageKeys []string
wantVendorName string
}
func testVideoResponsesProtocol(t *testing.T, testCase videoResponsesTestCase) {
t.Helper()
source, err := builtinplugins.Source(testCase.pluginKey)
require.NoError(t, err)
registry := jsplugin.NewRegistry()
plugin, err := registry.RegisterFactory(source, jsplugin.Options{Key: testCase.pluginKey})
require.NoError(t, err)
t.Run("claims every 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("declares documented usage facts", func(t *testing.T) {
keys := make([]string, 0, len(plugin.Meta.UsageSchema))
for key, schema := range plugin.Meta.UsageSchema {
keys = append(keys, key)
assert.NotEmpty(t, schema.Description, key)
}
sort.Strings(keys)
expected := append([]string(nil), testCase.wantUsageKeys...)
sort.Strings(expected)
assert.Equal(t, expected, keys)
})
var resolved map[string]any
t.Run("parses Responses input", func(t *testing.T) {
value, callErr := plugin.Engine.CallPath(
t.Context(),
"protocols",
[]string{"openai_responses", "decodeRequest"},
map[string]any{"body": map[string]any{"kind": "json", "value": testCase.requestBody}, "model": testCase.model, "stream": false},
)
require.NoError(t, callErr)
encoded, marshalErr := common.Marshal(value)
require.NoError(t, marshalErr)
require.NoError(t, common.Unmarshal(encoded, &resolved))
assert.Equal(t, testCase.model, resolved["model"])
assert.Equal(t, testCase.wantAction, resolved["action"])
requestBody, ok := resolved["requestBody"].(map[string]any)
require.True(t, ok)
for key, expected := range testCase.wantRequest {
assert.Equal(t, expected, requestBody[key], key)
}
})
t.Run("rejects malformed Responses input", func(t *testing.T) {
_, callErr := plugin.Engine.CallPath(
t.Context(),
"protocols",
[]string{"openai_responses", "decodeRequest"},
map[string]any{
"body": map[string]any{"kind": "json", "value": map[string]any{"model": testCase.model, "input": map[string]any{"text": "bad"}}},
"model": testCase.model,
},
)
require.ErrorContains(t, callErr, "input must be a string or array")
})
t.Run("extracts exactly the declared submit facts", func(t *testing.T) {
requestBody, ok := resolved["requestBody"].(map[string]any)
require.True(t, ok)
value, callErr := plugin.Engine.Call(t.Context(), "extractUsage", map[string]any{
"model": testCase.model,
"upstreamModel": testCase.model,
"action": testCase.wantAction,
"requestBody": requestBody,
})
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))
keys := make([]string, 0, len(facts))
for key := range facts {
keys = append(keys, key)
}
sort.Strings(keys)
expected := append([]string(nil), testCase.wantSubmitUsageKeys...)
if len(expected) == 0 {
expected = append([]string(nil), testCase.wantUsageKeys...)
}
sort.Strings(expected)
assert.Equal(t, expected, keys)
})
protocolContext := map[string]any{
"requestBody": map[string]any{"model": testCase.model},
"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{
"url": "https://upstream.example/video.mp4?signature=must-not-leak",
},
}
t.Run("renders stream state transitions", 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)
progress, decodeErr := relay.DecodePluginProtocolEventResult(progressValue, relay.DefaultPluginProtocolLimits())
require.NoError(t, decodeErr)
require.Len(t, progress.Events, 1)
require.NotNil(t, progress.Events[0].Progress)
assert.Equal(t, float64(42), *progress.Events[0].Progress)
duplicateValue, callErr := plugin.Engine.CallPath(
t.Context(),
"protocols",
[]string{"openai_responses", "renderEvents"},
protocolContext,
map[string]any{"task_id": "task_public", "status": "IN_PROGRESS", "progress": "42%"},
map[string]any{"status": "IN_PROGRESS", "progress": float64(42)},
)
require.NoError(t, callErr)
duplicate, decodeErr := relay.DecodePluginProtocolEventResult(duplicateValue, relay.DefaultPluginProtocolLimits())
require.NoError(t, decodeErr)
assert.Empty(t, duplicate.Events)
assert.False(t, duplicate.Done)
failureValue, callErr := plugin.Engine.CallPath(
t.Context(),
"protocols",
[]string{"openai_responses", "renderEvents"},
protocolContext,
map[string]any{"task_id": "task_public", "status": "FAILURE", "fail_reason": "blocked"},
)
require.NoError(t, callErr)
failure, decodeErr := relay.DecodePluginProtocolEventResult(failureValue, relay.DefaultPluginProtocolLimits())
require.NoError(t, decodeErr)
require.Len(t, failure.Events, 1)
assert.Equal(t, "error", failure.Events[0].Type)
assert.True(t, failure.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.Contains(t, text, "gateway.example")
assert.NotContains(t, text, "upstream.example")
})
t.Run("renders a valid final 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", testCase.model, 10, relay.DefaultPluginProtocolLimits())
response, finalErr := machine.FinalResponse(value, "SUCCESS")
require.NoError(t, finalErr)
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.Contains(t, text, "gateway.example")
assert.NotContains(t, text, "upstream.example")
metadata, ok := response["metadata"].(map[string]string)
require.True(t, ok)
assert.Equal(t, testCase.wantVendorName, metadata["vendor"])
})
t.Run("requires a host artifact", func(t *testing.T) {
_, callErr := plugin.Engine.CallPath(
t.Context(),
"protocols",
[]string{"openai_responses", "renderFinal"},
map[string]any{"requestBody": map[string]any{"model": testCase.model}},
successTask,
)
require.ErrorContains(t, callErr, "video artifact is unavailable")
})
}
+31
View File
@@ -0,0 +1,31 @@
package plugins_test
import "testing"
func TestViduResponsesProtocol(t *testing.T) {
testVideoResponsesProtocol(t, videoResponsesTestCase{
pluginKey: "vidu",
model: "viduq2",
requestBody: map[string]any{
"model": "viduq2",
"input": []any{map[string]any{"role": "user", "content": []any{
map[string]any{"type": "input_text", "text": "move between frames"},
map[string]any{"type": "input_image", "image_url": "https://cdn.example/first.png"},
map[string]any{"type": "input_image", "image_url": "https://cdn.example/last.png"},
}}},
"seconds": 8,
"size": "720p",
},
wantAction: "first_tail_to_video",
wantRequest: map[string]any{
"model": "viduq2",
"prompt": "move between frames",
"images": []any{"https://cdn.example/first.png", "https://cdn.example/last.png"},
"duration": float64(8),
"size": "720p",
},
wantUsageKeys: []string{"credits", "duration", "resolution"},
wantSubmitUsageKeys: []string{"duration", "resolution"},
wantVendorName: "vidu",
})
}