Commit 99974a81 by CaIon

feat(plugins): extend plugin metadata and icon support

parent 75e53320
...@@ -472,12 +472,20 @@ func validateChannel(channel *model.Channel, isAdd bool) error { ...@@ -472,12 +472,20 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
if len(pluginKey) > 30 { if len(pluginKey) > 30 {
return fmt.Errorf("task plugin key must not exceed 30 characters") return fmt.Errorf("task plugin key must not exceed 30 characters")
} }
if _, ok := jsplugin.DefaultRegistry.Get(pluginKey); !ok { plugin, ok := jsplugin.DefaultRegistry.Get(pluginKey)
if !ok {
return fmt.Errorf("task plugin %q is not registered", pluginKey) return fmt.Errorf("task plugin %q is not registered", pluginKey)
} }
if channel.BaseURL == nil || strings.TrimSpace(*channel.BaseURL) == "" { if channel.BaseURL == nil || strings.TrimSpace(*channel.BaseURL) == "" {
// The plugin default is persisted onto the channel instead of being
// resolved per request, so the destination host stays an auditable
// channel property that only an administrator edit can change.
if plugin.Meta.BaseURL == "" {
return fmt.Errorf("base URL is required for task plugin channels") return fmt.Errorf("base URL is required for task plugin channels")
} }
defaultBaseURL := plugin.Meta.BaseURL
channel.BaseURL = &defaultBaseURL
}
} }
if channel.Type == constant.ChannelTypeNewAPI && strings.TrimSpace(channel.GetBaseURL()) == "" { if channel.Type == constant.ChannelTypeNewAPI && strings.TrimSpace(channel.GetBaseURL()) == "" {
...@@ -625,6 +633,9 @@ func AddChannel(c *gin.Context) { ...@@ -625,6 +633,9 @@ func AddChannel(c *gin.Context) {
return return
} }
baseURLFromPluginDefault := addChannelRequest.Channel != nil &&
addChannelRequest.Channel.Type == constant.ChannelTypeTaskPlugin &&
(addChannelRequest.Channel.BaseURL == nil || strings.TrimSpace(*addChannelRequest.Channel.BaseURL) == "")
// 使用统一的校验函数 // 使用统一的校验函数
if err := validateChannel(addChannelRequest.Channel, true); err != nil { if err := validateChannel(addChannelRequest.Channel, true); err != nil {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
...@@ -709,11 +720,15 @@ func AddChannel(c *gin.Context) { ...@@ -709,11 +720,15 @@ func AddChannel(c *gin.Context) {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
recordManageAudit(c, "channel.create", map[string]interface{}{ createAudit := map[string]interface{}{
"name": addChannelRequest.Channel.Name, "name": addChannelRequest.Channel.Name,
"type": addChannelRequest.Channel.Type, "type": addChannelRequest.Channel.Type,
"count": len(channels), "count": len(channels),
}) }
if baseURLFromPluginDefault {
createAudit["base_url_source"] = "plugin_default"
}
recordManageAudit(c, "channel.create", createAudit)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
...@@ -981,6 +996,8 @@ func UpdateChannel(c *gin.Context) { ...@@ -981,6 +996,8 @@ func UpdateChannel(c *gin.Context) {
return return
} }
baseURLFromPluginDefault := channel.Type == constant.ChannelTypeTaskPlugin &&
(channel.BaseURL == nil || strings.TrimSpace(*channel.BaseURL) == "")
// 使用统一的校验函数 // 使用统一的校验函数
if err := validateChannel(&channel.Channel, false); err != nil { if err := validateChannel(&channel.Channel, false); err != nil {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
...@@ -1126,11 +1143,15 @@ func UpdateChannel(c *gin.Context) { ...@@ -1126,11 +1143,15 @@ func UpdateChannel(c *gin.Context) {
if channel.Key != "" && channel.Key != originChannel.Key { if channel.Key != "" && channel.Key != originChannel.Key {
changedFields = append(changedFields, "key") changedFields = append(changedFields, "key")
} }
recordManageAudit(c, "channel.update", map[string]interface{}{ updateAudit := map[string]interface{}{
"id": channel.Id, "id": channel.Id,
"name": channel.Name, "name": channel.Name,
"changed_fields": changedFields, "changed_fields": changedFields,
}) }
if baseURLFromPluginDefault {
updateAudit["base_url_source"] = "plugin_default"
}
recordManageAudit(c, "channel.update", updateAudit)
channel.Key = "" channel.Key = ""
clearChannelInfo(&channel.Channel) clearChannelInfo(&channel.Channel)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
......
...@@ -129,3 +129,38 @@ export function parseTaskResult() { return {}; } ...@@ -129,3 +129,38 @@ export function parseTaskResult() { return {}; }
assert.Contains(t, recorder.Body.String(), "task plugin channels require the task_plugin.bind permission") assert.Contains(t, recorder.Body.String(), "task plugin channels require the task_plugin.bind permission")
assert.Contains(t, recorder.Body.String(), `"success":false`) assert.Contains(t, recorder.Body.String(), `"success":false`)
} }
func TestAddChannelTaskPluginPersistsPluginDefaultBaseURLAndAuditsSource(t *testing.T) {
setupTaskPluginBindChannelTest(t)
for key, baseURLField := range map[string]string{"bind-default-url": `baseUrl: "http://10.0.0.5:8000/",`, "bind-no-default": ""} {
source := fmt.Sprintf(`
export const meta = {apiVersion: 1, key: %q, name: "Bind", version: "1.0.0", author: {name: "Test"}, %s models: ["doc"], fetchMode: "per_task"};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`, key, baseURLField)
_, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
require.NoError(t, err)
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister(key) })
}
body := func(pluginKey string) string {
return fmt.Sprintf(`{"mode":"single","channel":{"type":61,"name":"%s","key":"sk","models":"doc","group":"default","setting":"{\"task_plugin_key\":\"%s\"}"}}`, pluginKey, pluginKey)
}
noDefault := postAddChannel(t, 1, common.RoleRootUser, body("bind-no-default"))
assert.Contains(t, noDefault.Body.String(), "base URL is required for task plugin channels")
filled := postAddChannel(t, 1, common.RoleRootUser, body("bind-default-url"))
require.Contains(t, filled.Body.String(), `"success":true`)
var created model.Channel
require.NoError(t, model.DB.Where("name = ?", "bind-default-url").First(&created).Error)
require.NotNil(t, created.BaseURL)
assert.Equal(t, "http://10.0.0.5:8000", *created.BaseURL, "the normalized plugin default is stored on the channel row")
var audits []model.AuditLog
require.NoError(t, model.LOG_DB.Where("action = ?", "channel.create").Find(&audits).Error)
encoded, err := common.Marshal(audits)
require.NoError(t, err)
assert.Contains(t, string(encoded), `"base_url_source":"plugin_default"`)
}
...@@ -7,6 +7,7 @@ import ( ...@@ -7,6 +7,7 @@ import (
"github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/jsplugin" "github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
...@@ -39,3 +40,30 @@ export function parseTaskResult() { return {}; } ...@@ -39,3 +40,30 @@ export function parseTaskResult() { return {}; }
channel.BaseURL = nil channel.BaseURL = nil
require.ErrorContains(t, validateChannel(channel, false), "base URL is required") require.ErrorContains(t, validateChannel(channel, false), "base URL is required")
} }
func TestValidateTaskPluginChannelFillsPluginDefaultBaseURL(t *testing.T) {
source := `
export const meta = {apiVersion: 1, key: "channel-default-url", name: "Default URL", version: "1.0.0", author: {name: "Test"}, baseUrl: "http://127.0.0.1:8000/", models: ["doc"], fetchMode: "per_task"};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`
_, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
require.NoError(t, err)
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister("channel-default-url") })
bound := `{"task_plugin_key":"channel-default-url"}`
empty := " "
for _, baseURL := range []*string{nil, &empty} {
channel := &model.Channel{Type: constant.ChannelTypeTaskPlugin, Key: "sk", Setting: &bound, BaseURL: baseURL}
require.NoError(t, validateChannel(channel, true))
require.NotNil(t, channel.BaseURL)
assert.Equal(t, "http://127.0.0.1:8000", *channel.BaseURL, "normalized plugin default is persisted onto the channel")
}
explicit := "https://override.example.com"
channel := &model.Channel{Type: constant.ChannelTypeTaskPlugin, Setting: &bound, BaseURL: &explicit}
require.NoError(t, validateChannel(channel, false))
assert.Equal(t, explicit, *channel.BaseURL, "an administrator value is never replaced by the plugin default")
}
...@@ -6,6 +6,7 @@ import ( ...@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http"
"net/url" "net/url"
"sort" "sort"
"strings" "strings"
...@@ -30,6 +31,9 @@ type taskPluginUploadRequest struct { ...@@ -30,6 +31,9 @@ type taskPluginUploadRequest struct {
Remark string `json:"remark"` Remark string `json:"remark"`
Force bool `json:"force"` Force bool `json:"force"`
SourceSha256 string `json:"sourceSha256"` SourceSha256 string `json:"sourceSha256"`
// Icon carries the sidecar icon.svg / icon.png as a data URI. It is optional
// and stored separately from the source so the JavaScript stays readable.
Icon string `json:"icon"`
} }
func UploadTaskPlugin(c *gin.Context) { func UploadTaskPlugin(c *gin.Context) {
...@@ -59,6 +63,13 @@ func UploadTaskPlugin(c *gin.Context) { ...@@ -59,6 +63,13 @@ func UploadTaskPlugin(c *gin.Context) {
common.ApiErrorMsg(c, err.Error()) common.ApiErrorMsg(c, err.Error())
return return
} }
icon := strings.TrimSpace(request.Icon)
if icon != "" {
if _, _, err = jsplugin.DecodeIconDataURI(icon); err != nil {
common.ApiErrorMsg(c, err.Error())
return
}
}
enabled := true enabled := true
if request.Enabled != nil { if request.Enabled != nil {
enabled = *request.Enabled enabled = *request.Enabled
...@@ -72,7 +83,7 @@ func UploadTaskPlugin(c *gin.Context) { ...@@ -72,7 +83,7 @@ func UploadTaskPlugin(c *gin.Context) {
plugin := model.TaskPlugin{ plugin := model.TaskPlugin{
Key: loaded.Meta.Key, APIVersion: loaded.Meta.APIVersion, Version: loaded.Meta.Version, Key: loaded.Meta.Key, APIVersion: loaded.Meta.APIVersion, Version: loaded.Meta.Version,
Source: request.Source, SourceHash: fmt.Sprintf("%x", sha256.Sum256([]byte(request.Source))), Source: request.Source, SourceHash: fmt.Sprintf("%x", sha256.Sum256([]byte(request.Source))),
Enabled: enabled, Remark: request.Remark, Icon: icon, Enabled: enabled, Remark: request.Remark,
} }
if err = model.SaveTaskPlugin(&plugin); err != nil { if err = model.SaveTaskPlugin(&plugin); err != nil {
common.ApiError(c, err) common.ApiError(c, err)
...@@ -82,7 +93,7 @@ func UploadTaskPlugin(c *gin.Context) { ...@@ -82,7 +93,7 @@ func UploadTaskPlugin(c *gin.Context) {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
common.ApiSuccess(c, taskPluginDetail{Plugin: &plugin, Meta: loaded.Meta, Source: plugin.Source, Layer: "override"}) common.ApiSuccess(c, taskPluginDetail{Plugin: &plugin, Meta: loaded.Meta, Source: plugin.Source, Layer: "override", HasIcon: plugin.HasIcon()})
} }
func GetTaskPluginVersions(c *gin.Context) { func GetTaskPluginVersions(c *gin.Context) {
...@@ -100,6 +111,7 @@ type taskPluginListItem struct { ...@@ -100,6 +111,7 @@ type taskPluginListItem struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Active bool `json:"active"` Active bool `json:"active"`
SourceHash string `json:"source_hash"` SourceHash string `json:"source_hash"`
HasIcon bool `json:"has_icon"`
Remark string `json:"remark"` Remark string `json:"remark"`
RuntimeStatus string `json:"runtime_status"` RuntimeStatus string `json:"runtime_status"`
RuntimeError string `json:"runtime_error,omitempty"` RuntimeError string `json:"runtime_error,omitempty"`
...@@ -179,6 +191,7 @@ func ListTaskPlugins(c *gin.Context) { ...@@ -179,6 +191,7 @@ func ListTaskPlugins(c *gin.Context) {
item.Enabled = row.Enabled item.Enabled = row.Enabled
item.Active = row.Active item.Active = row.Active
item.SourceHash = row.SourceHash item.SourceHash = row.SourceHash
item.HasIcon = row.HasIcon()
item.Remark = row.Remark item.Remark = row.Remark
if message := runtimeErrors[key]; message != "" { if message := runtimeErrors[key]; message != "" {
item.RuntimeStatus = "compile_failed" item.RuntimeStatus = "compile_failed"
...@@ -199,6 +212,7 @@ func ListTaskPlugins(c *gin.Context) { ...@@ -199,6 +212,7 @@ func ListTaskPlugins(c *gin.Context) {
item.Source = "factory" item.Source = "factory"
item.Meta = factoryMeta item.Meta = factoryMeta
item.Enabled = !setting.IsTaskPluginFactoryDisabled(key) item.Enabled = !setting.IsTaskPluginFactoryDisabled(key)
_, _, item.HasIcon = plugins.Icon(key)
source, sourceErr := plugins.Source(key) source, sourceErr := plugins.Source(key)
if sourceErr == nil { if sourceErr == nil {
item.SourceHash = fmt.Sprintf("%x", sha256.Sum256([]byte(source))) item.SourceHash = fmt.Sprintf("%x", sha256.Sum256([]byte(source)))
...@@ -221,7 +235,12 @@ func ListTaskPlugins(c *gin.Context) { ...@@ -221,7 +235,12 @@ func ListTaskPlugins(c *gin.Context) {
} }
items = append(items, item) items = append(items, item)
} }
sort.Slice(items, func(i, j int) bool { return items[i].Meta.Key < items[j].Meta.Key }) sort.Slice(items, func(i, j int) bool {
if items[i].Meta.SortPriority != items[j].Meta.SortPriority {
return items[i].Meta.SortPriority > items[j].Meta.SortPriority
}
return items[i].Meta.Key < items[j].Meta.Key
})
common.ApiSuccess(c, items) common.ApiSuccess(c, items)
} }
...@@ -277,6 +296,39 @@ type taskPluginDetail struct { ...@@ -277,6 +296,39 @@ type taskPluginDetail struct {
Meta jsplugin.Meta `json:"meta"` Meta jsplugin.Meta `json:"meta"`
Source string `json:"source"` Source string `json:"source"`
Layer string `json:"layer"` Layer string `json:"layer"`
HasIcon bool `json:"has_icon"`
}
// GetTaskPluginIcon serves a plugin logo as an image. The active override wins
// (or the requested ?version=), then the factory sidecar. Data icons are only
// ever drawn through <img>, and the nosniff header keeps a browser from
// treating an SVG response as a document.
func GetTaskPluginIcon(c *gin.Context) {
key := c.Param("key")
icon := ""
plugin, err := model.GetTaskPluginVersion(key, c.Query("version"))
if err == nil {
icon = plugin.Icon
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
common.ApiError(c, err)
return
}
if icon == "" && c.Query("version") == "" {
icon = plugins.IconDataURI(key)
}
if icon == "" {
c.AbortWithStatus(http.StatusNotFound)
return
}
mediaType, data, err := jsplugin.DecodeIconDataURI(icon)
if err != nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
c.Header("Cache-Control", "private, max-age=3600")
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
c.Data(http.StatusOK, mediaType, data)
} }
func GetTaskPlugin(c *gin.Context) { func GetTaskPlugin(c *gin.Context) {
...@@ -289,7 +341,7 @@ func GetTaskPlugin(c *gin.Context) { ...@@ -289,7 +341,7 @@ func GetTaskPlugin(c *gin.Context) {
common.ApiErrorMsg(c, compileErr.Error()) common.ApiErrorMsg(c, compileErr.Error())
return return
} }
common.ApiSuccess(c, taskPluginDetail{Plugin: plugin, Meta: loaded.Meta, Source: plugin.Source, Layer: "override"}) common.ApiSuccess(c, taskPluginDetail{Plugin: plugin, Meta: loaded.Meta, Source: plugin.Source, Layer: "override", HasIcon: plugin.HasIcon()})
return return
} }
if !errors.Is(err, gorm.ErrRecordNotFound) || version != "" { if !errors.Is(err, gorm.ErrRecordNotFound) || version != "" {
...@@ -306,7 +358,8 @@ func GetTaskPlugin(c *gin.Context) { ...@@ -306,7 +358,8 @@ func GetTaskPlugin(c *gin.Context) {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
common.ApiSuccess(c, taskPluginDetail{Meta: loaded.Meta, Source: source, Layer: "factory"}) _, _, hasIcon := plugins.Icon(key)
common.ApiSuccess(c, taskPluginDetail{Meta: loaded.Meta, Source: source, Layer: "factory", HasIcon: hasIcon})
} }
type taskPluginDryRunRequest struct { type taskPluginDryRunRequest struct {
...@@ -590,15 +643,34 @@ func GetTaskPluginOptions(c *gin.Context) { ...@@ -590,15 +643,34 @@ func GetTaskPluginOptions(c *gin.Context) {
continue continue
} }
seen[meta.Key] = true seen[meta.Key] = true
hasIcon := false
if layer == 0 {
if row, rowErr := model.GetTaskPluginVersion(meta.Key, ""); rowErr == nil {
hasIcon = row.HasIcon()
}
} else {
_, _, hasIcon = plugins.Icon(meta.Key)
}
options = append(options, gin.H{ options = append(options, gin.H{
"key": meta.Key, "key": meta.Key,
"name": meta.Name, "name": meta.Name,
"icon": meta.Icon,
"hasIcon": hasIcon,
"baseUrl": meta.BaseURL,
"sortPriority": meta.SortPriority,
"website": meta.Website,
"models": meta.Models, "models": meta.Models,
"usageSchema": meta.UsageSchema, "usageSchema": meta.UsageSchema,
}) })
} }
} }
sort.Slice(options, func(i, j int) bool { return options[i]["key"].(string) < options[j]["key"].(string) }) sort.Slice(options, func(i, j int) bool {
left, right := options[i]["sortPriority"].(int), options[j]["sortPriority"].(int)
if left != right {
return left > right
}
return options[i]["key"].(string) < options[j]["key"].(string)
})
common.ApiSuccess(c, options) common.ApiSuccess(c, options)
} }
......
package controller package controller
import ( import (
"bytes"
"crypto/sha256" "crypto/sha256"
"encoding/base64"
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
...@@ -356,11 +358,13 @@ func TestMasterSwitchEmptiesOptionsAndKeepsList(t *testing.T) { ...@@ -356,11 +358,13 @@ func TestMasterSwitchEmptiesOptionsAndKeepsList(t *testing.T) {
assert.Equal(t, "kling", item.Meta.Key) assert.Equal(t, "kling", item.Meta.Key)
} }
func TestGetTaskPluginOptionsIncludesUsageSchema(t *testing.T) { func TestGetTaskPluginOptionsIncludesUsageSchemaIconAndBaseURL(t *testing.T) {
setupTaskPluginControllerTest(t)
const key = "usage-options-probe" const key = "usage-options-probe"
source := ` source := `
export const meta = { export const meta = {
apiVersion: 1, key: "usage-options-probe", name: "Usage Options", version: "1.0.0", author: {name: "Test"}, apiVersion: 1, key: "usage-options-probe", name: "Usage Options", version: "1.0.0", author: {name: "Test"},
icon: "text:UO", baseUrl: "http://localhost:9000/",
models: ["usage-options-model"], fetchMode: "per_task", models: ["usage-options-model"], fetchMode: "per_task",
usageSchema: {seconds: {type: "number", unit: "second", description: "Generated media duration."}} usageSchema: {seconds: {type: "number", unit: "second", description: "Generated media duration."}}
}; };
...@@ -383,6 +387,8 @@ export function parseTaskResult() { return {}; } ...@@ -383,6 +387,8 @@ export function parseTaskResult() { return {}; }
Success bool `json:"success"` Success bool `json:"success"`
Data []struct { Data []struct {
Key string `json:"key"` Key string `json:"key"`
Icon string `json:"icon"`
BaseURL string `json:"baseUrl"`
UsageSchema map[string]jsplugin.UsageFieldSchema `json:"usageSchema"` UsageSchema map[string]jsplugin.UsageFieldSchema `json:"usageSchema"`
} `json:"data"` } `json:"data"`
} }
...@@ -394,6 +400,8 @@ export function parseTaskResult() { return {}; } ...@@ -394,6 +400,8 @@ export function parseTaskResult() { return {}; }
} }
assert.Equal(t, "second", option.UsageSchema["seconds"].Unit) assert.Equal(t, "second", option.UsageSchema["seconds"].Unit)
assert.Equal(t, "Generated media duration.", option.UsageSchema["seconds"].Description["en"]) assert.Equal(t, "Generated media duration.", option.UsageSchema["seconds"].Description["en"])
assert.Equal(t, "text:UO", option.Icon)
assert.Equal(t, "http://localhost:9000", option.BaseURL, "the drawer prefills the normalized plugin default")
return return
} }
t.Fatal("task plugin option not found") t.Fatal("task plugin option not found")
...@@ -935,11 +943,13 @@ export function parseTaskResult() { return {}; } ...@@ -935,11 +943,13 @@ export function parseTaskResult() { return {}; }
} }
func TestDeletePureFactoryPluginIsRejected(t *testing.T) { func TestDeletePureFactoryPluginIsRejected(t *testing.T) {
for _, query := range []string{"", "?force=true"} {
t.Run(query, func(t *testing.T) {
setupTaskPluginControllerTest(t) setupTaskPluginControllerTest(t)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder) context, _ := gin.CreateTestContext(recorder)
context.Params = gin.Params{{Key: "key", Value: "kling"}, {Key: "version", Value: "1.0.0"}} context.Params = gin.Params{{Key: "key", Value: "kling"}, {Key: "version", Value: "1.0.0"}}
context.Request = httptest.NewRequest(http.MethodDelete, "/api/plugin/task/kling/versions/1.0.0", nil) context.Request = httptest.NewRequest(http.MethodDelete, "/api/plugin/task/kling/versions/1.0.0"+query, nil)
DeleteTaskPluginVersion(context) DeleteTaskPluginVersion(context)
...@@ -947,6 +957,8 @@ func TestDeletePureFactoryPluginIsRejected(t *testing.T) { ...@@ -947,6 +957,8 @@ func TestDeletePureFactoryPluginIsRejected(t *testing.T) {
assert.Contains(t, recorder.Body.String(), "factory plugins cannot be deleted") assert.Contains(t, recorder.Body.String(), "factory plugins cannot be deleted")
_, ok := jsplugin.DefaultRegistry.Get("kling") _, ok := jsplugin.DefaultRegistry.Get("kling")
assert.True(t, ok) assert.True(t, ok)
})
}
} }
func TestUploadTaskPluginSourceSha256(t *testing.T) { func TestUploadTaskPluginSourceSha256(t *testing.T) {
...@@ -1118,3 +1130,106 @@ func TestUpdateTaskPluginMarketplaceSourcesValidation(t *testing.T) { ...@@ -1118,3 +1130,106 @@ func TestUpdateTaskPluginMarketplaceSourcesValidation(t *testing.T) {
}) })
} }
} }
func TestTaskPluginDisplayOrderAndMetadata(t *testing.T) {
setupTaskPluginControllerTest(t)
const website = "https://example.com/plugins"
keys := []string{"display-low", "display-zero", "display-beta", "display-alpha"}
priorities := []int{-10, 0, 50, 50}
for i, key := range keys {
source := strings.Replace(taskPluginControllerTestSource(key, "1.0.0"), "apiVersion: 1,", fmt.Sprintf("apiVersion: 1, sortPriority: %d, website: %q,", priorities[i], website), 1)
_, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
require.NoError(t, err)
cleanupTaskPluginControllerRuntime(t, key)
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{Key: key, Version: "1.0.0", APIVersion: 1, Source: source, Enabled: true}))
}
for _, endpoint := range []string{"list", "options"} {
t.Run(endpoint, func(t *testing.T) {
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodGet, "/", nil)
var metas []jsplugin.Meta
if endpoint == "list" {
ListTaskPlugins(context)
var response struct {
Success bool
Data []taskPluginListItem
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
for _, item := range response.Data {
metas = append(metas, item.Meta)
}
} else {
GetTaskPluginOptions(context)
var response struct {
Success bool
Data []jsplugin.Meta
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
metas = response.Data
}
var ordered []string
for _, meta := range metas {
if strings.HasPrefix(meta.Key, "display-") {
ordered = append(ordered, meta.Key)
assert.Equal(t, website, meta.Website)
}
}
assert.Equal(t, []string{"display-alpha", "display-beta", "display-zero", "display-low"}, ordered)
})
}
}
func TestUploadTaskPluginStoresSidecarIconAndServesIt(t *testing.T) {
setupTaskPluginControllerTest(t)
const key = "icon-sidecar"
source := taskPluginControllerTestSource(key, "1.0.0")
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister(key) })
svgIcon := "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"><circle cx="4" cy="4" r="4"/></svg>`))
upload := func(icon string) *httptest.ResponseRecorder {
body, err := common.Marshal(map[string]any{"source": source, "icon": icon})
require.NoError(t, err)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task", bytes.NewReader(body))
context.Request.Header.Set("Content-Type", "application/json")
UploadTaskPlugin(context)
return recorder
}
rejected := upload("data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(`<svg xmlns="http://www.w3.org/2000/svg"><script>1</script></svg>`)))
assert.Contains(t, rejected.Body.String(), `"success":false`)
assert.Contains(t, rejected.Body.String(), "script")
_, err := model.GetTaskPluginVersion(key, "1.0.0")
require.ErrorIs(t, err, gorm.ErrRecordNotFound, "a rejected icon must not store the plugin")
accepted := upload(svgIcon)
require.Contains(t, accepted.Body.String(), `"success":true`)
assert.Contains(t, accepted.Body.String(), `"has_icon":true`)
assert.NotContains(t, accepted.Body.String(), "base64,", "icon bytes never travel inside detail JSON")
stored, err := model.GetTaskPluginVersion(key, "1.0.0")
require.NoError(t, err)
assert.Equal(t, svgIcon, stored.Icon)
item := listTaskPluginItem(t, key)
assert.True(t, item.HasIcon)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Params = gin.Params{{Key: "key", Value: key}}
context.Request = httptest.NewRequest(http.MethodGet, "/api/plugin/task/"+key+"/icon", nil)
GetTaskPluginIcon(context)
require.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, "image/svg+xml", recorder.Header().Get("Content-Type"))
assert.Equal(t, "nosniff", recorder.Header().Get("X-Content-Type-Options"))
assert.Contains(t, recorder.Body.String(), "<circle")
missing := httptest.NewRecorder()
context, _ = gin.CreateTestContext(missing)
context.Params = gin.Params{{Key: "key", Value: "no-such-plugin"}}
context.Request = httptest.NewRequest(http.MethodGet, "/api/plugin/task/no-such-plugin/icon", nil)
GetTaskPluginIcon(context)
assert.Equal(t, http.StatusNotFound, missing.Code)
}
...@@ -20,9 +20,9 @@ export type ProtocolClaim = ...@@ -20,9 +20,9 @@ export type ProtocolClaim =
| {name: "openai_responses"; supports: readonly ResponsesMode[]; models?: readonly string[]} | {name: "openai_responses"; supports: readonly ResponsesMode[]; models?: readonly string[]}
| {name: "openai_video"; models?: readonly string[]}; | {name: "openai_video"; models?: readonly string[]};
export type LocalizedText = string | ({ en: string } & Record<string, string>); export type LocalizedText = string | ({ en: string } & Record<string, string>);
export type UsageFieldSchema = {type: "number"; unit: "second" | "count" | "token" | "credit"; description?: LocalizedText} | {type: "boolean"; description?: LocalizedText} | {enum: readonly string[]; description?: LocalizedText}; export type UsageFieldSchema = {type: "number"; unit: "second" | "count" | "token" | "credit"; description?: LocalizedText} | {type: "boolean"; description?: LocalizedText} | {enum: readonly string[]; description?: LocalizedText; enumLabels?: Readonly<Record<string, LocalizedText>>};
export type UsageExample = {label: string; facts: Readonly<Record<string, string | number>>}; export type UsageExample = {label: string; facts: Readonly<Record<string, string | number>>};
export interface Meta {apiVersion: 1; key: string; name: string; icon?: string; description?: LocalizedText; version: string; author: {name: string; url?: string}; channelTypes?: readonly number[]; models: readonly string[]; fetchMode: "per_task" | "batch"; allowedHosts?: readonly string[]; routes?: readonly NativeRoute[]; protocols?: readonly ProtocolClaim[]; usageSchema?: Readonly<Record<string, UsageFieldSchema>>; usageExamples?: readonly UsageExample[]; auth?: "none" | "api_key" | "vertex_oauth" | {type: "none" | "api_key" | "oauth2_jwt"}} export interface Meta {sortPriority?: number; website?: string; apiVersion: 1; key: string; name: string; icon?: string; description?: LocalizedText; version: string; author: {name: string; url?: string}; baseUrl?: string; channelTypes?: readonly number[]; models: readonly string[]; fetchMode: "per_task" | "batch"; allowedHosts?: readonly string[]; routes?: readonly NativeRoute[]; protocols?: readonly ProtocolClaim[]; usageSchema?: Readonly<Record<string, UsageFieldSchema>>; usageExamples?: readonly UsageExample[]; auth?: "none" | "api_key" | "vertex_oauth" | {type: "none" | "api_key" | "oauth2_jwt"}}
export interface TaskView {task_id: string; status: string; progress?: string; fail_reason?: string; created_at?: number; updated_at?: number; data?: unknown; properties?: Record<string, unknown>} export interface TaskView {task_id: string; status: string; progress?: string; fail_reason?: string; created_at?: number; updated_at?: number; data?: unknown; properties?: Record<string, unknown>}
export interface DriverContext {requestBody: unknown; requestHeaders: Readonly<Record<string, string>>; action: string; model: string; upstreamModel: string; baseUrl: string; apiKey?: string; authHeader: string; files: readonly FileReference[]; publicTaskId: string; originTasks?: readonly {taskId: string; upstreamTaskId: string; action: string; status: string; data: unknown}[]} export interface DriverContext {requestBody: unknown; requestHeaders: Readonly<Record<string, string>>; action: string; model: string; upstreamModel: string; baseUrl: string; apiKey?: string; authHeader: string; files: readonly FileReference[]; publicTaskId: string; originTasks?: readonly {taskId: string; upstreamTaskId: string; action: string; status: string; data: unknown}[]}
export interface TaskQueryContext {taskId: string; publicTaskId: string; action: string; model: string; upstreamModel: string; baseUrl: string; apiKey?: string; authHeader: string; auth?: unknown; data: unknown; state: unknown} export interface TaskQueryContext {taskId: string; publicTaskId: string; action: string; model: string; upstreamModel: string; baseUrl: string; apiKey?: string; authHeader: string; auth?: unknown; data: unknown; state: unknown}
......
...@@ -6,8 +6,11 @@ ...@@ -6,8 +6,11 @@
"additionalProperties": false, "additionalProperties": false,
"required": ["apiVersion", "key", "name", "version", "author", "models", "fetchMode"], "required": ["apiVersion", "key", "name", "version", "author", "models", "fetchMode"],
"properties": { "properties": {
"sortPriority": {"type": "integer", "minimum": -2147483648, "maximum": 2147483647, "default": 0},
"website": {"oneOf": [{"const": ""}, {"type": "string", "format": "uri", "pattern": "^[Hh][Tt][Tt][Pp][Ss]://[^/@?#\\s]+(?:[/?#]|$)"}], "description": "Absolute HTTPS URL with a valid ASCII hostname (use punycode for internationalized domains) and no credentials; validated by the host."},
"apiVersion": {"const": 1}, "key": {"type": "string"}, "name": {"type": "string"}, "icon": {"type": "string", "maxLength": 128}, "description": {"$ref": "#/$defs/localizedText"}, "version": {"type": "string"}, "apiVersion": {"const": 1}, "key": {"type": "string"}, "name": {"type": "string"}, "icon": {"type": "string", "maxLength": 128}, "description": {"$ref": "#/$defs/localizedText"}, "version": {"type": "string"},
"author": {"type": "object", "required": ["name"], "additionalProperties": false, "properties": {"name": {"type": "string"}, "url": {"type": "string", "format": "uri"}}}, "author": {"type": "object", "required": ["name"], "additionalProperties": false, "properties": {"name": {"type": "string"}, "url": {"type": "string", "format": "uri"}}},
"baseUrl": {"type": "string", "format": "uri", "pattern": "^https?://", "maxLength": 191},
"channelTypes": {"type": "array", "uniqueItems": true, "items": {"type": "integer", "minimum": 1}}, "models": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "channelTypes": {"type": "array", "uniqueItems": true, "items": {"type": "integer", "minimum": 1}}, "models": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}},
"fetchMode": {"enum": ["per_task", "batch"]}, "allowedHosts": {"type": "array", "uniqueItems": true, "items": {"type": "string"}}, "fetchMode": {"enum": ["per_task", "batch"]}, "allowedHosts": {"type": "array", "uniqueItems": true, "items": {"type": "string"}},
"protocols": {"type": "array", "uniqueItems": true, "items": {"oneOf": [ "protocols": {"type": "array", "uniqueItems": true, "items": {"oneOf": [
...@@ -15,9 +18,35 @@ ...@@ -15,9 +18,35 @@
{"type": "object", "additionalProperties": false, "required": ["name", "supports"], "properties": {"name": {"const": "openai_responses"}, "supports": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"enum": ["stream", "sync", "background"]}}, "models": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}}}}, {"type": "object", "additionalProperties": false, "required": ["name", "supports"], "properties": {"name": {"const": "openai_responses"}, "supports": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"enum": ["stream", "sync", "background"]}}, "models": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}}}},
{"type": "object", "additionalProperties": false, "required": ["name"], "properties": {"name": {"const": "openai_video"}, "models": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}}}} {"type": "object", "additionalProperties": false, "required": ["name"], "properties": {"name": {"const": "openai_video"}, "models": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}}}}
]}}, ]}},
"routes": {"type": "array", "items": {"$ref": "#/$defs/route"}}, "usageSchema": {"type": "object", "additionalProperties": {"type": "object", "properties": {"description": {"$ref": "#/$defs/localizedText"}}}}, "usageExamples": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["label", "facts"], "properties": {"label": {"type": "string"}, "facts": {"type": "object"}}}}, "auth": {} "routes": {"type": "array", "items": {"$ref": "#/$defs/route"}}, "usageSchema": {"type": "object", "additionalProperties": {"$ref": "#/$defs/usageField"}}, "usageExamples": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["label", "facts"], "properties": {"label": {"type": "string"}, "facts": {"type": "object"}}}}, "auth": {}
}, },
"$defs": { "$defs": {
"usageField": {
"type": "object",
"additionalProperties": false,
"properties": {
"type": {"enum": ["number", "boolean"]},
"unit": {"enum": ["second", "count", "token", "credit"]},
"enum": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string"}},
"description": {"$ref": "#/$defs/usageLabel"},
"enumLabels": {
"type": "object",
"additionalProperties": {"$ref": "#/$defs/usageLabel"},
"description": "Optional display names keyed by declared enum values; the host validates membership. Missing entries display the original value."
}
},
"oneOf": [
{"required": ["enum"], "not": {"anyOf": [{"required": ["type"]}, {"required": ["unit"]}]}},
{"required": ["type", "unit"], "properties": {"type": {"const": "number"}}, "not": {"anyOf": [{"required": ["enum"]}, {"required": ["enumLabels"]}]}},
{"required": ["type"], "properties": {"type": {"const": "boolean"}}, "not": {"anyOf": [{"required": ["unit"]}, {"required": ["enum"]}, {"required": ["enumLabels"]}]}}
]
},
"usageLabel": {
"allOf": [
{"$ref": "#/$defs/localizedText"},
{"if": {"type": "string"}, "then": {"maxLength": 256}, "else": {"additionalProperties": {"maxLength": 256}}}
]
},
"localizedText": { "localizedText": {
"oneOf": [ "oneOf": [
{"type": "string", "minLength": 1}, {"type": "string", "minLength": 1},
......
...@@ -429,6 +429,13 @@ func updatePricing() { ...@@ -429,6 +429,13 @@ func updatePricing() {
for key, field := range plugin.Meta.UsageSchema { for key, field := range plugin.Meta.UsageSchema {
field.Enum = append([]string(nil), field.Enum...) field.Enum = append([]string(nil), field.Enum...)
field.Description = maps.Clone(field.Description) field.Description = maps.Clone(field.Description)
if field.EnumLabels != nil {
labels := make(map[string]jsplugin.LocalizedText, len(field.EnumLabels))
for value, label := range field.EnumLabels {
labels[value] = maps.Clone(label)
}
field.EnumLabels = labels
}
pricing.BillingUsageSchema[key] = field pricing.BillingUsageSchema[key] = field
} }
if len(plugin.Meta.UsageExamples) > 0 { if len(plugin.Meta.UsageExamples) > 0 {
......
...@@ -30,7 +30,8 @@ func TestPricingCarriesTaskUsageSchemaAndRefreshesWithPluginGeneration(t *testin ...@@ -30,7 +30,8 @@ func TestPricingCarriesTaskUsageSchemaAndRefreshesWithPluginGeneration(t *testin
resetPricingEndpointTestTables(t) resetPricingEndpointTestTables(t)
const pluginKey = "pricing-usage-probe" const pluginKey = "pricing-usage-probe"
initialSource := pricingUsagePluginSource("1.0.0", `{ initialSource := pricingUsagePluginSource("1.0.0", `{
seconds: {type: "number", unit: "second", description: "Estimated duration."} seconds: {type: "number", unit: "second", description: "Estimated duration."},
action: {enum:["video"],enumLabels:{video:{en:"Generate video",zh:"生成视频"}}}
}`) }`)
_, err := jsplugin.DefaultRegistry.Register(initialSource, jsplugin.Options{}) _, err := jsplugin.DefaultRegistry.Register(initialSource, jsplugin.Options{})
require.NoError(t, err) require.NoError(t, err)
...@@ -45,6 +46,7 @@ func TestPricingCarriesTaskUsageSchemaAndRefreshesWithPluginGeneration(t *testin ...@@ -45,6 +46,7 @@ func TestPricingCarriesTaskUsageSchemaAndRefreshesWithPluginGeneration(t *testin
require.Contains(t, initialPricing, "ordinary-model") require.Contains(t, initialPricing, "ordinary-model")
assert.Equal(t, "second", initialPricing["pricing-usage-model"].BillingUsageSchema["seconds"].Unit) assert.Equal(t, "second", initialPricing["pricing-usage-model"].BillingUsageSchema["seconds"].Unit)
assert.Equal(t, "Estimated duration.", initialPricing["pricing-usage-model"].BillingUsageSchema["seconds"].Description["en"]) assert.Equal(t, "Estimated duration.", initialPricing["pricing-usage-model"].BillingUsageSchema["seconds"].Description["en"])
assert.Equal(t, "生成视频", initialPricing["pricing-usage-model"].BillingUsageSchema["action"].EnumLabels["video"]["zh"])
assert.Nil(t, initialPricing["ordinary-model"].BillingUsageSchema) assert.Nil(t, initialPricing["ordinary-model"].BillingUsageSchema)
updatedSource := pricingUsagePluginSource("1.1.0", `{ updatedSource := pricingUsagePluginSource("1.1.0", `{
......
...@@ -41,12 +41,25 @@ type TaskPlugin struct { ...@@ -41,12 +41,25 @@ type TaskPlugin struct {
Version string `json:"version" gorm:"size:64;not null;uniqueIndex:uk_task_plugin_key_version,priority:2"` Version string `json:"version" gorm:"size:64;not null;uniqueIndex:uk_task_plugin_key_version,priority:2"`
Source string `json:"source" gorm:"type:text;not null"` Source string `json:"source" gorm:"type:text;not null"`
SourceHash string `json:"source_hash" gorm:"size:64;not null"` SourceHash string `json:"source_hash" gorm:"size:64;not null"`
// Icon is the plugin logo shipped as a sidecar icon.svg / icon.png next to
// plugin.js, stored as a data URI so one column carries both the media
// type and the bytes. It never travels inside list or detail JSON; the UI
// loads it through GET /api/plugin/task/:key/icon. size matches the
// 512 KiB icon cap and makes GORM emit mediumtext on MySQL (a bare TEXT
// column there holds only 64 KiB), varchar(524288) on PostgreSQL, and text
// on SQLite.
Icon string `json:"-" gorm:"size:524288"`
Enabled bool `json:"enabled" gorm:"not null"` Enabled bool `json:"enabled" gorm:"not null"`
Active bool `json:"active" gorm:"not null;index"` Active bool `json:"active" gorm:"not null;index"`
CreatedAt int64 `json:"created_at" gorm:"not null"` CreatedAt int64 `json:"created_at" gorm:"not null"`
Remark string `json:"remark" gorm:"type:text"` Remark string `json:"remark" gorm:"type:text"`
} }
// HasIcon reports whether this version ships a logo.
func (plugin TaskPlugin) HasIcon() bool {
return plugin.Icon != ""
}
func SaveTaskPlugin(plugin *TaskPlugin) error { func SaveTaskPlugin(plugin *TaskPlugin) error {
return DB.Transaction(func(tx *gorm.DB) error { return DB.Transaction(func(tx *gorm.DB) error {
var existing TaskPlugin var existing TaskPlugin
...@@ -55,7 +68,12 @@ func SaveTaskPlugin(plugin *TaskPlugin) error { ...@@ -55,7 +68,12 @@ func SaveTaskPlugin(plugin *TaskPlugin) error {
if existing.SourceHash != plugin.SourceHash { if existing.SourceHash != plugin.SourceHash {
return errors.New("plugin key and version already exist with different source") return errors.New("plugin key and version already exist with different source")
} }
if err = tx.Model(&existing).Updates(map[string]any{"enabled": plugin.Enabled, "remark": plugin.Remark}).Error; err != nil { updates := map[string]any{"enabled": plugin.Enabled, "remark": plugin.Remark}
if plugin.Icon != "" {
updates["icon"] = plugin.Icon
existing.Icon = plugin.Icon
}
if err = tx.Model(&existing).Updates(updates).Error; err != nil {
return err return err
} }
existing.Enabled = plugin.Enabled existing.Enabled = plugin.Enabled
......
...@@ -354,6 +354,9 @@ func TestValidateRequestURL(t *testing.T) { ...@@ -354,6 +354,9 @@ func TestValidateRequestURL(t *testing.T) {
{name: "same host", requestURL: "https://api.example.com/v1/task", baseURL: "https://api.example.com/v1"}, {name: "same host", requestURL: "https://api.example.com/v1/task", baseURL: "https://api.example.com/v1"},
{name: "default port", requestURL: "https://api.example.com:443/v1/task", baseURL: "https://api.example.com"}, {name: "default port", requestURL: "https://api.example.com:443/v1/task", baseURL: "https://api.example.com"},
{name: "approved host", requestURL: "https://upload.example.com/task", baseURL: "https://api.example.com", allowedHosts: []string{"upload.example.com"}}, {name: "approved host", requestURL: "https://upload.example.com/task", baseURL: "https://api.example.com", allowedHosts: []string{"upload.example.com"}},
{name: "approved host with port", requestURL: "http://192.168.1.10:8080/task", baseURL: "http://192.168.1.10:8000", allowedHosts: []string{"192.168.1.10:8080"}},
{name: "approved host with explicit default port", requestURL: "https://upload.example.com/task", baseURL: "https://api.example.com", allowedHosts: []string{"upload.example.com:443"}},
{name: "approved host port mismatch", requestURL: "http://192.168.1.10:9000/task", baseURL: "http://192.168.1.10:8000", allowedHosts: []string{"192.168.1.10:8080"}, wantError: "not allowed"},
{name: "subdomain is not implicit", requestURL: "https://evil.api.example.com/task", baseURL: "https://api.example.com", wantError: "not allowed"}, {name: "subdomain is not implicit", requestURL: "https://evil.api.example.com/task", baseURL: "https://api.example.com", wantError: "not allowed"},
{name: "userinfo trick", requestURL: "https://api.example.com@evil.example/task", baseURL: "https://api.example.com", wantError: "not allowed"}, {name: "userinfo trick", requestURL: "https://api.example.com@evil.example/task", baseURL: "https://api.example.com", wantError: "not allowed"},
{name: "relative URL", requestURL: "/v1/task", baseURL: "https://api.example.com", wantError: "absolute"}, {name: "relative URL", requestURL: "/v1/task", baseURL: "https://api.example.com", wantError: "absolute"},
......
package jsplugin
import (
"bytes"
"encoding/base64"
"encoding/xml"
"fmt"
"io"
"strings"
"unicode"
)
// MaxIconDataURIBytes bounds an uploaded plugin logo. It is deliberately
// generous: the plugin source itself is capped at 1 MiB, so this only keeps a
// logo from dwarfing the plugin it decorates.
const MaxIconDataURIBytes = 512 * 1024
var pngSignature = []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}
// DecodeIconDataURI parses a plugin logo shipped as a sidecar icon.svg or
// icon.png file and re-encoded by the uploader as
// data:image/png;base64,... or data:image/svg+xml;base64,.... It returns the
// media type and the raw image bytes. The admin UI renders logos only through
// <img>, which already blocks scripts and external loads; the SVG checks here
// are defense in depth so a hostile payload is rejected at upload instead of
// being stored.
func DecodeIconDataURI(icon string) (string, []byte, error) {
if len(icon) > MaxIconDataURIBytes {
return "", nil, fmt.Errorf("plugin icon must not exceed %d bytes", MaxIconDataURIBytes)
}
mediaType, payload, ok := strings.Cut(strings.TrimPrefix(icon, "data:"), ";base64,")
if !strings.HasPrefix(icon, "data:") || !ok || (mediaType != "image/png" && mediaType != "image/svg+xml") {
return "", nil, fmt.Errorf("plugin icon must be data:image/png;base64,... or data:image/svg+xml;base64,...")
}
decoded, err := base64.StdEncoding.Strict().DecodeString(payload)
if err != nil {
return "", nil, fmt.Errorf("plugin icon payload is not valid base64")
}
if err := ValidateIconImage(mediaType, decoded); err != nil {
return "", nil, err
}
return mediaType, decoded, nil
}
// ValidateIconImage checks raw PNG or SVG bytes for a plugin logo. PNG must
// carry the PNG signature. SVG must be well-formed XML rooted at svg without
// script or foreignObject elements, event-handler attributes, directives,
// javascript: values, or absolute http(s) references.
func ValidateIconImage(mediaType string, data []byte) error {
if mediaType == "image/png" {
if !bytes.HasPrefix(data, pngSignature) {
return fmt.Errorf("plugin icon PNG payload is not a PNG image")
}
return nil
}
if mediaType != "image/svg+xml" {
return fmt.Errorf("plugin icon must be image/png or image/svg+xml")
}
decoder := xml.NewDecoder(bytes.NewReader(data))
rootSeen := false
styleDepth := 0
for {
token, err := decoder.Token()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("plugin icon SVG is not well-formed XML")
}
switch element := token.(type) {
case xml.Directive:
return fmt.Errorf("plugin icon SVG must not contain a DOCTYPE or other directives")
case xml.ProcInst:
if element.Target != "xml" {
return fmt.Errorf("plugin icon SVG must not contain processing instructions")
}
case xml.StartElement:
local := strings.ToLower(element.Name.Local)
if !rootSeen {
if local != "svg" {
return fmt.Errorf("plugin icon SVG root element must be svg")
}
rootSeen = true
}
if local == "script" || local == "foreignobject" {
return fmt.Errorf("plugin icon SVG must not contain %s elements", element.Name.Local)
}
if local == "style" {
styleDepth++
}
for _, attr := range element.Attr {
if attr.Name.Space == "xmlns" || (attr.Name.Space == "" && attr.Name.Local == "xmlns") {
continue
}
if strings.HasPrefix(strings.ToLower(attr.Name.Local), "on") {
return fmt.Errorf("plugin icon SVG must not contain event handler attributes")
}
if svgValueReferencesExternal(attr.Value) {
return fmt.Errorf("plugin icon SVG must not reference scripts or external resources")
}
}
case xml.EndElement:
if strings.ToLower(element.Name.Local) == "style" && styleDepth > 0 {
styleDepth--
}
case xml.CharData:
if styleDepth > 0 && (svgValueReferencesExternal(string(element)) || strings.Contains(strings.ToLower(string(element)), "@import")) {
return fmt.Errorf("plugin icon SVG must not reference scripts or external resources")
}
}
}
if !rootSeen {
return fmt.Errorf("plugin icon SVG root element must be svg")
}
return nil
}
func svgValueReferencesExternal(value string) bool {
compact := strings.Map(func(character rune) rune {
if unicode.IsSpace(character) || unicode.IsControl(character) {
return -1
}
return unicode.ToLower(character)
}, value)
return strings.Contains(compact, "javascript:") || strings.Contains(compact, "http://") || strings.Contains(compact, "https://")
}
...@@ -23,7 +23,9 @@ func ValidateRequestURL(requestURL, baseURL string, allowedHosts []string) error ...@@ -23,7 +23,9 @@ func ValidateRequestURL(requestURL, baseURL string, allowedHosts []string) error
return nil return nil
} }
for _, allowed := range allowedHosts { for _, allowed := range allowedHosts {
allowedURL, parseErr := url.Parse("https://" + strings.TrimSpace(allowed)) // Parse with the request scheme so "host:443" matches an https request
// the same way an explicit default port on the request URL does.
allowedURL, parseErr := url.Parse(request.Scheme + "://" + strings.TrimSpace(allowed))
if parseErr == nil && requestHost == canonicalHost(allowedURL) { if parseErr == nil && requestHost == canonicalHost(allowedURL) {
return nil return nil
} }
......
...@@ -2,13 +2,14 @@ package plugins ...@@ -2,13 +2,14 @@ package plugins
import ( import (
"embed" "embed"
"encoding/base64"
"fmt" "fmt"
"io/fs" "io/fs"
"github.com/QuantumNous/new-api/pkg/jsplugin" "github.com/QuantumNous/new-api/pkg/jsplugin"
) )
//go:embed tasks/*/plugin.js //go:embed tasks
var taskPlugins embed.FS var taskPlugins embed.FS
func init() { func init() {
...@@ -28,6 +29,11 @@ func init() { ...@@ -28,6 +29,11 @@ func init() {
if _, registerErr := jsplugin.DefaultRegistry.RegisterFactory(source, jsplugin.Options{Key: key}); registerErr != nil { if _, registerErr := jsplugin.DefaultRegistry.RegisterFactory(source, jsplugin.Options{Key: key}); registerErr != nil {
panic(fmt.Sprintf("register embedded task plugin %s: %v", key, registerErr)) 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))
}
}
} }
} }
...@@ -39,3 +45,26 @@ func Source(key string) (string, error) { ...@@ -39,3 +45,26 @@ func Source(key string) (string, error) {
} }
return string(source), nil 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)
}
...@@ -7,7 +7,7 @@ export const meta = { ...@@ -7,7 +7,7 @@ export const meta = {
en: "Alibaba Cloud Bailian Wanxiang video generation (text-to-video and image-to-video)", en: "Alibaba Cloud Bailian Wanxiang video generation (text-to-video and image-to-video)",
zh: "阿里云百炼万相视频生成(文生视频、图生视频)", zh: "阿里云百炼万相视频生成(文生视频、图生视频)",
}, },
version: "1.1.0", version: "1.1.1",
author: { name: "QuantumNous" }, author: { name: "QuantumNous" },
channelTypes: [17], channelTypes: [17],
models: [ models: [
...@@ -24,14 +24,17 @@ export const meta = { ...@@ -24,14 +24,17 @@ export const meta = {
], ],
fetchMode: "per_task", fetchMode: "per_task",
usageSchema: { usageSchema: {
// Requested video duration in seconds.
seconds: { seconds: {
type: "number", type: "number",
unit: "second", unit: "second",
description: { en: "Requested video duration in seconds.", zh: "请求的视频时长,单位为秒。" }, description: { en: "Video generation unit price", zh: "视频生成单价" },
}, },
// Requested output video resolution.
resolution: { resolution: {
enum: ["480P", "720P", "1080P"], enum: ["480P", "720P", "1080P"],
description: { en: "Requested output video resolution.", zh: "请求的输出视频分辨率。" }, enumLabels: { "480P": { en: "480P", zh: "480P" }, "720P": { en: "720P", zh: "720P" }, "1080P": { en: "1080P", zh: "1080P" } },
description: { en: "Output video resolution", zh: "输出视频分辨率" },
}, },
}, },
routes: [ routes: [
......
...@@ -7,7 +7,7 @@ export const meta = { ...@@ -7,7 +7,7 @@ export const meta = {
en: "Volcengine Doubao Seedance video generation (text-to-video, image-to-video, and video-to-video)", en: "Volcengine Doubao Seedance video generation (text-to-video, image-to-video, and video-to-video)",
zh: "火山引擎豆包 Seedance 视频生成(文生视频、图生视频、视频生视频)", zh: "火山引擎豆包 Seedance 视频生成(文生视频、图生视频、视频生视频)",
}, },
version: "1.0.1", version: "1.0.2",
author: { name: "QuantumNous" }, author: { name: "QuantumNous" },
channelTypes: [54, 45], // VolcEngine-type channels serve Ark video models with the same wire format channelTypes: [54, 45], // VolcEngine-type channels serve Ark video models with the same wire format
models: [ models: [
...@@ -22,27 +22,28 @@ export const meta = { ...@@ -22,27 +22,28 @@ export const meta = {
], ],
fetchMode: "per_task", fetchMode: "per_task",
usageSchema: { usageSchema: {
// Upstream billing tokens (estimated at submit, actual on completion).
tokens: { tokens: {
type: "number", type: "number",
unit: "token", unit: "token",
description: { description: { en: "Billing token unit price", zh: "计费 Token 单价" },
en: "Upstream billing tokens (estimated at submit, actual on completion).",
zh: "上游计费 token(提交时预估,完成后按实际值)。",
},
}, },
// Output video resolution; Seedance token unit price varies by resolution tier.
resolution: { resolution: {
enum: ["480p", "720p", "1080p", "4k"], enum: ["480p", "720p", "1080p", "4k"],
description: { enumLabels: {
en: "Output video resolution; Seedance token unit price varies by resolution tier.", "480p": { en: "480p", zh: "480p" },
zh: "输出视频分辨率;Seedance token 单价随分辨率档位变化。", "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: { video_input: {
enum: ["none", "video"], enum: ["none", "video"],
description: { enumLabels: { none: { en: "No reference video", zh: "无参考视频" }, video: { en: "With reference video", zh: "有参考视频" } },
en: "Whether the request includes reference video input; Seedance prices video-to-video tokens at a lower unit rate.", description: { en: "Reference video input", zh: "参考视频输入" },
zh: "请求是否包含参考视频输入;Seedance 对视频生视频 token 按更低单价计费。",
},
}, },
}, },
// Official Ark formula tokens = (input + output seconds) × W × H × 24 / 1024, // Official Ark formula tokens = (input + output seconds) × W × H × 24 / 1024,
......
...@@ -7,26 +7,23 @@ export const meta = { ...@@ -7,26 +7,23 @@ export const meta = {
en: "Google Veo video generation on the Gemini API (text-to-video and image-to-video)", en: "Google Veo video generation on the Gemini API (text-to-video and image-to-video)",
zh: "Google Veo 视频生成(文生视频、图生视频),Gemini API 版本", zh: "Google Veo 视频生成(文生视频、图生视频),Gemini API 版本",
}, },
version: "1.0.1", version: "1.0.2",
author: { name: "QuantumNous" }, author: { name: "QuantumNous" },
channelTypes: [24], 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"], 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", fetchMode: "per_task",
usageSchema: { usageSchema: {
// Requested video duration in seconds. Allowed values: 4, 6, 8.
seconds: { seconds: {
type: "number", type: "number",
unit: "second", unit: "second",
description: { description: { en: "Video generation unit price", zh: "视频生成单价" },
en: "Requested video duration in seconds. Allowed values: 4, 6, 8.",
zh: "请求的视频时长,单位为秒。允许值为 4、6、8。",
},
}, },
// Requested video output resolution. Veo prices differ per resolution tier.
resolution: { resolution: {
enum: ["720p", "1080p", "4k"], enum: ["720p", "1080p", "4k"],
description: { enumLabels: { "720p": { en: "720p", zh: "720p" }, "1080p": { en: "1080p", zh: "1080p" }, "4k": { en: "4k", zh: "4k" } },
en: "Requested video output resolution. Veo prices differ per resolution tier.", description: { en: "Output video resolution", zh: "输出视频分辨率" },
zh: "请求的输出视频分辨率。Veo 各分辨率档位计费不同。",
},
}, },
}, },
usageExamples: [ usageExamples: [
......
...@@ -7,7 +7,7 @@ export const meta = { ...@@ -7,7 +7,7 @@ export const meta = {
en: "MiniMax Hailuo video generation (text-to-video, image-to-video, and MiniMax-H3 multimodal reference)", en: "MiniMax Hailuo video generation (text-to-video, image-to-video, and MiniMax-H3 multimodal reference)",
zh: "MiniMax 海螺视频生成(文生视频、图生视频、MiniMax-H3 多模态参考生视频)", zh: "MiniMax 海螺视频生成(文生视频、图生视频、MiniMax-H3 多模态参考生视频)",
}, },
version: "1.1.2", version: "1.1.3",
author: { name: "QuantumNous" }, author: { name: "QuantumNous" },
channelTypes: [35], channelTypes: [35],
models: [ models: [
...@@ -24,33 +24,35 @@ export const meta = { ...@@ -24,33 +24,35 @@ export const meta = {
], ],
fetchMode: "per_task", fetchMode: "per_task",
usageSchema: { 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: { seconds: {
type: "number", type: "number",
unit: "second", unit: "second",
description: { description: { en: "Video generation unit price", zh: "视频生成单价" },
en: "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.",
zh: "请求的视频时长,单位为秒。MiniMax-H3 允许 4 到 15;Hailuo 2.3/02/2.3-Fast 允许 6 或 10;01 系列允许 6。",
},
}, },
// Requested output video resolution.
resolution: { resolution: {
enum: ["512P", "768P", "720P", "1080P", "2K"], enum: ["512P", "768P", "720P", "1080P", "2K"],
description: { en: "Requested output video resolution.", zh: "请求的输出视频分辨率。" }, 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: { input_images: {
type: "number", type: "number",
unit: "count", unit: "count",
description: { description: { en: "Input image unit price", zh: "输入图片单价" },
en: "H3 input image count (estimated at submit, actual on completion).",
zh: "H3 输入图片数量(提交时预估,完成后按实际值)。",
},
}, },
// H3 input video duration in seconds (reserved at the request maximum, actual on completion).
input_video_seconds: { input_video_seconds: {
type: "number", type: "number",
unit: "second", unit: "second",
description: { description: { en: "Input video unit price", zh: "输入视频单价" },
en: "H3 input video duration in seconds (reserved at the request maximum, actual on completion).",
zh: "H3 输入视频时长,单位为秒(提交时按请求上限预留,完成后按实际值)。",
},
}, },
}, },
usageExamples: [ usageExamples: [
......
...@@ -7,23 +7,28 @@ export const meta = { ...@@ -7,23 +7,28 @@ export const meta = {
en: "Volcengine Jimeng video generation (text-to-video, image-to-video, and first-and-last-frame)", en: "Volcengine Jimeng video generation (text-to-video, image-to-video, and first-and-last-frame)",
zh: "火山引擎即梦视频生成(文生视频、图生视频、首尾帧)", zh: "火山引擎即梦视频生成(文生视频、图生视频、首尾帧)",
}, },
version: "1.0.1", version: "1.0.2",
author: { name: "QuantumNous" }, author: { name: "QuantumNous" },
channelTypes: [51], channelTypes: [51],
models: ["jimeng_vgfm_t2v_l20"], models: ["jimeng_vgfm_t2v_l20"],
fetchMode: "per_task", fetchMode: "per_task",
usageSchema: { usageSchema: {
// Requested video duration in seconds. S2.0 Pro is fixed at 5; 3.0 req_keys allow 5 or 10.
seconds: { seconds: {
type: "number", type: "number",
unit: "second", unit: "second",
description: { description: { en: "Video generation unit price", zh: "视频生成单价" },
en: "Requested video duration in seconds. S2.0 Pro is fixed at 5; 3.0 req_keys allow 5 or 10.",
zh: "请求的视频时长,单位为秒。S2.0 Pro 固定为 5;3.0 req_keys 允许 5 或 10。",
},
}, },
// Product tier derived from the final outbound req_key.
product: { product: {
enum: ["s2_pro", "v30_720p", "v30_1080p", "v30_pro"], enum: ["s2_pro", "v30_720p", "v30_1080p", "v30_pro"],
description: { en: "Product tier derived from the final outbound req_key.", zh: "由最终出站 req_key 推导出的产品档位。" }, 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: [ usageExamples: [
......
...@@ -7,19 +7,17 @@ export const meta = { ...@@ -7,19 +7,17 @@ export const meta = {
en: "Kuaishou Kling video generation (text-to-video and image-to-video)", en: "Kuaishou Kling video generation (text-to-video and image-to-video)",
zh: "快手可灵视频生成(文生视频、图生视频)", zh: "快手可灵视频生成(文生视频、图生视频)",
}, },
version: "1.0.1", version: "1.0.2",
author: { name: "QuantumNous" }, author: { name: "QuantumNous" },
channelTypes: [50], channelTypes: [50],
models: ["kling-v1", "kling-v1-6", "kling-v2-master"], models: ["kling-v1", "kling-v1-6", "kling-v2-master"],
fetchMode: "per_task", fetchMode: "per_task",
usageSchema: { usageSchema: {
// Kling final unit deduction (estimated at submit, actual on completion).
units: { units: {
type: "number", type: "number",
unit: "credit", unit: "credit",
description: { description: { en: "Kling credit unit price", zh: "可灵资源包单位单价" },
en: "Kling final unit deduction (estimated at submit, actual on completion).",
zh: "可灵最终单位消耗(提交时预估,完成后按实际值)。",
},
}, },
}, },
usageExamples: [ usageExamples: [
......
...@@ -7,20 +7,28 @@ export const meta = { ...@@ -7,20 +7,28 @@ export const meta = {
en: "OpenAI Sora video generation (text-to-video, image-to-video, and remix)", en: "OpenAI Sora video generation (text-to-video, image-to-video, and remix)",
zh: "OpenAI Sora 视频生成(文生视频、图生视频、remix)", zh: "OpenAI Sora 视频生成(文生视频、图生视频、remix)",
}, },
version: "1.0.1", version: "1.0.2",
channelTypes: [55, 1], // OpenAI-type channels natively serve sora with the same wire format channelTypes: [55, 1], // OpenAI-type channels natively serve sora with the same wire format
author: { name: "QuantumNous" }, author: { name: "QuantumNous" },
models: ["sora-2", "sora-2-pro"], models: ["sora-2", "sora-2-pro"],
fetchMode: "per_task", fetchMode: "per_task",
usageSchema: { usageSchema: {
// Requested video duration in seconds.
seconds: { seconds: {
type: "number", type: "number",
unit: "second", unit: "second",
description: { en: "Requested video duration in seconds.", zh: "请求的视频时长,单位为秒。" }, description: { en: "Video generation unit price", zh: "视频生成单价" },
}, },
// Requested output video dimensions.
size: { size: {
enum: ["720x1280", "1280x720", "1792x1024", "1024x1792"], enum: ["720x1280", "1280x720", "1792x1024", "1024x1792"],
description: { en: "Requested output video dimensions.", zh: "请求的输出视频尺寸。" }, 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"], protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
......
...@@ -9,18 +9,24 @@ export const meta = { ...@@ -9,18 +9,24 @@ export const meta = {
en: "SunoAPI project music and lyrics generation", en: "SunoAPI project music and lyrics generation",
zh: "SunoAPI 项目 音乐与歌词生成", zh: "SunoAPI 项目 音乐与歌词生成",
}, },
version: "1.0.2", version: "1.0.3",
author: { name: "QuantumNous" }, author: { name: "QuantumNous" },
channelTypes: [36], channelTypes: [36],
models: ["suno_music", "suno_lyrics"], models: ["suno_music", "suno_lyrics"],
fetchMode: "batch", fetchMode: "batch",
usageSchema: { usageSchema: {
// Number of generated music or lyrics clips.
clips: { clips: {
type: "number", type: "number",
unit: "count", unit: "count",
description: { en: "Number of generated music or lyrics clips.", zh: "生成的音乐或歌词片段数量。" }, 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: "生成歌曲或歌词" },
}, },
action: { enum: ["music", "lyrics"], description: { en: "Suno generation action.", zh: "Suno 生成动作。" } },
}, },
protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }], protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }],
routes: [ routes: [
......
...@@ -7,31 +7,29 @@ export const meta = { ...@@ -7,31 +7,29 @@ export const meta = {
en: "Google Veo video generation on Vertex AI (text-to-video and image-to-video)", en: "Google Veo video generation on Vertex AI (text-to-video and image-to-video)",
zh: "Google Veo 视频生成(文生视频、图生视频),Vertex AI 版本", zh: "Google Veo 视频生成(文生视频、图生视频),Vertex AI 版本",
}, },
version: "1.0.1", version: "1.0.2",
channelTypes: [41], channelTypes: [41],
author: { name: "QuantumNous" }, 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"], 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", fetchMode: "per_task",
auth: { type: "oauth2_jwt" }, auth: { type: "oauth2_jwt" },
usageSchema: { usageSchema: {
// Requested video duration in seconds. Allowed values: 4, 6, 8.
seconds: { seconds: {
type: "number", type: "number",
unit: "second", unit: "second",
description: { description: { en: "Video generation unit price", zh: "视频生成单价" },
en: "Requested video duration in seconds. Allowed values: 4, 6, 8.",
zh: "请求的视频时长,单位为秒。允许值为 4、6、8。",
},
}, },
// Requested video output resolution.
resolution: { resolution: {
enum: ["720p", "1080p", "4k"], enum: ["720p", "1080p", "4k"],
description: { en: "Requested video output resolution.", zh: "请求的输出视频分辨率。" }, 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: { generate_audio: {
type: "boolean", type: "boolean",
description: { description: { en: "Whether audio is generated", zh: "是否生成音频" },
en: "Whether audio is generated. Default true. Audio and muted tiers have different prices.",
zh: "是否生成音频。默认为 true。有声与静音档位计费不同。",
},
}, },
}, },
usageExamples: [ usageExamples: [
......
...@@ -7,25 +7,34 @@ export const meta = { ...@@ -7,25 +7,34 @@ export const meta = {
en: "Shengshu Vidu video generation (text-to-video, image-to-video, first-and-last-frame, and reference-to-video)", en: "Shengshu Vidu video generation (text-to-video, image-to-video, first-and-last-frame, and reference-to-video)",
zh: "生数 Vidu 视频生成(文生视频、图生视频、首尾帧、参考生视频)", zh: "生数 Vidu 视频生成(文生视频、图生视频、首尾帧、参考生视频)",
}, },
version: "1.0.1", version: "1.0.2",
author: { name: "QuantumNous" }, author: { name: "QuantumNous" },
channelTypes: [52], channelTypes: [52],
models: ["viduq2", "viduq1", "vidu2.0", "vidu1.5"], models: ["viduq2", "viduq1", "vidu2.0", "vidu1.5"],
fetchMode: "per_task", fetchMode: "per_task",
usageSchema: { usageSchema: {
// estimated/actual Vidu credits
credits: { credits: {
type: "number", type: "number",
unit: "credit", unit: "credit",
description: { en: "estimated/actual Vidu credits", zh: "预估/实际的 Vidu 积分" }, description: { en: "Vidu credit unit price", zh: "Vidu 积分单价" },
}, },
// Requested video duration in seconds.
duration: { duration: {
type: "number", type: "number",
unit: "second", unit: "second",
description: { en: "Requested video duration in seconds.", zh: "请求的视频时长,单位为秒。" }, description: { en: "Video generation unit price", zh: "视频生成单价" },
}, },
// Requested output video resolution.
resolution: { resolution: {
enum: ["360p", "540p", "720p", "1080p"], enum: ["360p", "540p", "720p", "1080p"],
description: { en: "Requested output video resolution.", zh: "请求的输出视频分辨率。" }, 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; // credits is 0 in examples because this plugin does not estimate vendor credits;
......
...@@ -255,6 +255,7 @@ func SetApiRouter(router *gin.Engine) { ...@@ -255,6 +255,7 @@ func SetApiRouter(router *gin.Engine) {
taskPluginRoute.GET("/marketplace/sources", controller.GetTaskPluginMarketplaceSources) taskPluginRoute.GET("/marketplace/sources", controller.GetTaskPluginMarketplaceSources)
taskPluginRoute.PUT("/marketplace/sources", controller.UpdateTaskPluginMarketplaceSources) taskPluginRoute.PUT("/marketplace/sources", controller.UpdateTaskPluginMarketplaceSources)
taskPluginRoute.GET("/:key", controller.GetTaskPlugin) taskPluginRoute.GET("/:key", controller.GetTaskPlugin)
taskPluginRoute.GET("/:key/icon", controller.GetTaskPluginIcon)
taskPluginRoute.GET("/:key/versions", controller.GetTaskPluginVersions) taskPluginRoute.GET("/:key/versions", controller.GetTaskPluginVersions)
taskPluginRoute.POST("/:key/activate", controller.ActivateTaskPlugin) taskPluginRoute.POST("/:key/activate", controller.ActivateTaskPlugin)
taskPluginRoute.POST("/:key/status", controller.SetTaskPluginStatus) taskPluginRoute.POST("/:key/status", controller.SetTaskPluginStatus)
......
...@@ -498,6 +498,9 @@ func TestPluginPathOwnershipIsExclusiveAcrossMethods(t *testing.T) { ...@@ -498,6 +498,9 @@ func TestPluginPathOwnershipIsExclusiveAcrossMethods(t *testing.T) {
beta := compileRouterPlugin(t, "beta-owner", "1.0.0", `[ beta := compileRouterPlugin(t, "beta-owner", "1.0.0", `[
{method: "POST", path: "/shared/jobs/:task_id", type: "submit"} {method: "POST", path: "/shared/jobs/:task_id", type: "submit"}
]`) ]`)
// Display priority must not change exclusive route ownership.
alpha.Meta.SortPriority = -100
beta.Meta.SortPriority = 100
handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) { handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
c.String(http.StatusOK, binding.Plugin.Meta.Key) c.String(http.StatusOK, binding.Plugin.Meta.Key)
}) })
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment