Files
new-api/plugins/builtin_plugins_test.go
T
CaIon 4c34f25a49 feat(channel): bind multiple task plugins to New API channels
A New API (type 60) channel proxies another gateway that may host many
task plugins, so the channel setting gains task_extend_plugin_keys; the
single task_plugin_key stays valid there as well. Channel selection,
shared-model candidate rebinding, the task_plugin.bind permission gate,
plugin usage counting and the disable cascade (which now unbinds gateway
channels instead of disabling them) all honour the union of both fields,
and plugins may no longer claim channel type 60 in channelTypes.

Plugins declare meta.upstreams and the host injects ctx.upstream into
every driver hook context. On a type-60 channel the driver addresses its
own prefixed native routes (doubao, alibaba, kling, jimeng) and the host
sends the channel key as a Bearer header for every auth type; polling
threads the channel type through ChannelMeta and tasks keep the key
that submitted them. Only plugins declaring new_api can be bound.

The channel form offers an "Upstream task plugins" selector for type-60
channels, MultiSelect options can carry an icon, and the plugin API docs,
type declarations and schema describe the additive apiVersion 1
extension.
2026-09-20 17:51:51 +08:00

367 lines
15 KiB
Go

package plugins
import (
"io/fs"
"strings"
"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"])
})
}
}
func TestBuiltInPluginsAddressNewAPIUpstreamOnNativeRoutes(t *testing.T) {
generation := jsplugin.DefaultRegistry.Generation()
require.NotNil(t, generation)
for _, key := range []string{"hailuo", "google", "vidu", "vertex-ai"} {
plugin, found := generation.Get(key)
require.True(t, found, key)
assert.False(t, plugin.Meta.SupportsUpstream(jsplugin.UpstreamKindNewAPI), "%s has no native routes and must not be bindable to a New API channel", key)
}
const base = "https://upstream.example"
cases := []struct {
key string
driver map[string]any
vendorKey string
vendorAuthPrefix string
vendorSubmit string
gatewaySubmit string
vendorQuery string
gatewayQuery string
content string
legacyKeyHeuristic bool
}{
{
key: "doubao",
driver: map[string]any{"action": "text_to_video", "model": "doubao-seedance-1-0-pro-250528", "upstreamModel": "doubao-seedance-1-0-pro-250528", "requestBody": map[string]any{"model": "doubao-seedance-1-0-pro-250528", "prompt": "a cat"}},
vendorKey: "vendor-key",
vendorAuthPrefix: "Bearer vendor-key",
vendorSubmit: "/api/v3/contents/generations/tasks",
gatewaySubmit: "/doubao/api/v3/contents/generations/tasks",
vendorQuery: "/api/v3/contents/generations/tasks/tid",
gatewayQuery: "/doubao/api/v3/contents/generations/tasks/tid",
},
{
key: "alibaba",
driver: map[string]any{"action": "text_to_video", "model": "wan2.2-t2v-plus", "upstreamModel": "wan2.2-t2v-plus", "requestBody": map[string]any{"model": "wan2.2-t2v-plus", "prompt": "a cat"}},
vendorKey: "vendor-key",
vendorAuthPrefix: "Bearer vendor-key",
vendorSubmit: "/api/v1/services/aigc/video-generation/video-synthesis",
gatewaySubmit: "/ali/api/v1/services/aigc/video-generation/video-synthesis",
vendorQuery: "/api/v1/tasks/tid",
gatewayQuery: "/ali/api/v1/tasks/tid",
},
{
key: "kling",
driver: map[string]any{"action": "text_to_video", "model": "kling-v1", "upstreamModel": "kling-v1", "requestBody": map[string]any{"prompt": "a cat"}},
vendorKey: "ak|sk",
vendorAuthPrefix: "Bearer ey",
vendorSubmit: "/v1/videos/text2video",
gatewaySubmit: "/kling/v1/videos/text2video",
vendorQuery: "/v1/videos/text2video/tid",
gatewayQuery: "/kling/v1/videos/text2video/tid",
legacyKeyHeuristic: true,
},
{
key: "jimeng",
driver: map[string]any{"action": "text_to_video", "model": "jimeng_vgfm_t2v_l20", "upstreamModel": "jimeng_vgfm_t2v_l20", "requestBody": map[string]any{"prompt": "a cat"}},
vendorKey: "ak|sk",
vendorAuthPrefix: "HMAC-SHA256",
vendorSubmit: "/?Action=CVSync2AsyncSubmitTask&Version=2022-08-31",
gatewaySubmit: "/jimeng/?Action=CVSync2AsyncSubmitTask&Version=2022-08-31",
vendorQuery: "/?Action=CVSync2AsyncGetResult&Version=2022-08-31",
gatewayQuery: "/jimeng/?Action=CVSync2AsyncGetResult&Version=2022-08-31",
legacyKeyHeuristic: true,
},
{
key: "sunoapi",
driver: map[string]any{"action": "MUSIC", "model": "suno_music", "upstreamModel": "suno_music", "requestBody": map[string]any{"prompt": "a song"}},
vendorKey: "vendor-key",
vendorAuthPrefix: "Bearer vendor-key",
vendorSubmit: "/suno/submit/MUSIC",
gatewaySubmit: "/suno/submit/MUSIC",
vendorQuery: "/suno/fetch",
gatewayQuery: "/suno/fetch",
},
{
key: "sora",
driver: map[string]any{"action": "text_to_video", "model": "sora-2", "upstreamModel": "sora-2", "requestBody": map[string]any{"prompt": "a cat"}},
vendorKey: "vendor-key",
vendorAuthPrefix: "Bearer vendor-key",
vendorSubmit: "/v1/videos",
gatewaySubmit: "/v1/videos",
vendorQuery: "/v1/videos/tid",
gatewayQuery: "/v1/videos/tid",
content: "/v1/videos/tid/content",
},
}
variants := []struct {
name string
upstream map[string]any
gateway bool
legacyKey bool
}{
{"vendor signal", map[string]any{"kind": "vendor"}, false, false},
{"no signal from an older host", nil, false, false},
{"new_api signal", map[string]any{"kind": "new_api"}, true, false},
{"legacy sk- key without a signal", nil, true, true},
}
for _, tc := range cases {
t.Run(tc.key, func(t *testing.T) {
plugin, found := generation.Get(tc.key)
require.True(t, found)
require.True(t, plugin.Meta.SupportsUpstream(jsplugin.UpstreamKindNewAPI), "%s must declare new_api to be bindable to a New API channel", tc.key)
call := func(hook string, args ...any) map[string]any {
value, err := plugin.Engine.Call(t.Context(), hook, args...)
require.NoError(t, err, hook)
encoded, err := common.Marshal(value)
require.NoError(t, err)
var descriptor map[string]any
require.NoError(t, common.Unmarshal(encoded, &descriptor))
return descriptor
}
for _, variant := range variants {
if variant.legacyKey && !tc.legacyKeyHeuristic {
continue
}
t.Run(variant.name, func(t *testing.T) {
apiKey := tc.vendorKey
if variant.legacyKey {
apiKey = "sk-legacy"
} else if variant.gateway {
apiKey = "sk-gateway"
}
authHeader := apiKey
if variant.gateway && !variant.legacyKey {
authHeader = "Bearer " + apiKey
}
ctx := map[string]any{"baseUrl": base, "apiKey": apiKey, "authHeader": authHeader, "publicTaskId": "task_pub", "requestHeaders": map[string]any{}, "files": []any{}}
for key, value := range tc.driver {
ctx[key] = value
}
if variant.upstream != nil {
ctx["upstream"] = variant.upstream
}
wantSubmit, wantQuery := tc.vendorSubmit, tc.vendorQuery
if variant.gateway {
wantSubmit, wantQuery = tc.gatewaySubmit, tc.gatewayQuery
}
submit := call("buildSubmitRequest", ctx)
assert.Equal(t, base+wantSubmit, submit["url"])
headers, _ := submit["headers"].(map[string]any)
authorization, _ := headers["Authorization"].(string)
if variant.gateway {
assert.Equal(t, "Bearer "+apiKey, authorization, "a gateway receives its own token as a Bearer header")
} else {
assert.True(t, strings.HasPrefix(authorization, tc.vendorAuthPrefix), "vendor Authorization %q", authorization)
}
queryCtx := map[string]any{"taskId": "tid", "publicTaskId": "task_pub", "action": tc.driver["action"], "model": tc.driver["model"], "upstreamModel": tc.driver["upstreamModel"], "baseUrl": base, "apiKey": apiKey, "authHeader": authHeader, "data": nil, "state": nil}
if variant.upstream != nil {
queryCtx["upstream"] = variant.upstream
}
var query map[string]any
if plugin.Meta.FetchMode == "batch" {
query = call("buildBatchQueryRequest", queryCtx, []any{queryCtx})
} else {
query = call("buildQueryRequest", queryCtx)
}
assert.Equal(t, base+wantQuery, query["url"])
if tc.content == "" {
return
}
contentCtx := map[string]any{"baseUrl": base, "apiKey": apiKey, "authHeader": authHeader, "artifactKey": "video", "upstreamTaskId": "tid", "data": map[string]any{}, "clientRequest": map[string]any{"method": "GET", "headers": map[string]any{}}}
if variant.upstream != nil {
contentCtx["upstream"] = variant.upstream
}
assert.Equal(t, base+tc.content, call("buildContentRequest", contentCtx)["url"])
})
}
})
}
t.Run("alibaba asks only DashScope for an interleaved image stream", func(t *testing.T) {
plugin, found := generation.Get("alibaba")
require.True(t, found)
for _, variant := range []struct {
name string
upstream map[string]any
wantResponseType string
wantSSEHeader bool
}{
{"vendor", map[string]any{"kind": "vendor"}, "sse", true},
{"new_api", map[string]any{"kind": "new_api"}, "json", false},
} {
t.Run(variant.name, func(t *testing.T) {
value, err := plugin.Engine.Call(t.Context(), "buildSubmitRequest", map[string]any{
"baseUrl": base, "apiKey": "key", "authHeader": "key", "publicTaskId": "task_pub", "upstream": variant.upstream,
"model": "wan2.6-image", "upstreamModel": "wan2.6-image",
"requestBody": map[string]any{"model": "wan2.6-image", "prompt": "a cat", "metadata": map[string]any{"upstream_mode": "sync", "parameters": map[string]any{"enable_interleave": true}}},
})
require.NoError(t, err)
encoded, err := common.Marshal(value)
require.NoError(t, err)
var descriptor struct {
URL string `json:"url"`
ResponseType string `json:"responseType"`
Headers map[string]string `json:"headers"`
}
require.NoError(t, common.Unmarshal(encoded, &descriptor))
assert.Equal(t, variant.wantResponseType, descriptor.ResponseType, "a gateway native route aggregates the vendor stream and answers with JSON")
_, hasSSEHeader := descriptor.Headers["X-DashScope-Sse"]
assert.Equal(t, variant.wantSSEHeader, hasSSEHeader)
})
}
})
}