diff --git a/controller/channel_test_tianyiyun_test.go b/controller/channel_test_tianyiyun_test.go index c6b6c65..2355923 100644 --- a/controller/channel_test_tianyiyun_test.go +++ b/controller/channel_test_tianyiyun_test.go @@ -29,8 +29,11 @@ func TestRequiredTaskChannelTypeForTianyiYunSeedanceModelUsesAllowedFamily(t *te c := newControllerJSONContext(t, "/api/v3/contents/generations/tasks", `{"model":"Doubao-Seedance-2.0"}`) require.Equal(t, 0, requiredTaskChannelTypeForRequest(c)) + // The native /api/v3 path also accepts official Volcengine (DoubaoVideo) + // channels alongside the seedance asset family. require.ElementsMatch(t, - service.VideoAssetChannelTypesForFamily(service.VideoAssetFamilySeedance), + append(service.VideoAssetChannelTypesForFamily(service.VideoAssetFamilySeedance), + constant.ChannelTypeDoubaoVideo), allowedTaskChannelTypesForRequest(c), ) } diff --git a/controller/relay.go b/controller/relay.go index d7266f7..d6dc657 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -367,7 +367,9 @@ func requiredTaskChannelTypeForRequest(c *gin.Context) int { func allowedTaskChannelTypesForRequest(c *gin.Context) []int { if strings.HasPrefix(c.Request.URL.Path, "/api/v3/contents/generations/tasks") { - return service.VideoAssetChannelTypesForFamily(service.VideoAssetFamilySeedance) + // Keep in sync with allowedChannelTypesForRequest in middleware/distributor.go + return append(service.VideoAssetChannelTypesForFamily(service.VideoAssetFamilySeedance), + constant.ChannelTypeDoubaoVideo) } if isKlingAipingNativePath(c.Request.URL.Path) { return service.VideoAssetChannelTypesForFamily(service.VideoAssetFamilyKling) diff --git a/middleware/distributor.go b/middleware/distributor.go index 7f577fe..809e1c2 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -345,7 +345,11 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) { func allowedChannelTypesForRequest(c *gin.Context, modelName string) []int { if strings.HasPrefix(c.Request.URL.Path, "/api/v3/contents/generations/tasks") { - return service.VideoAssetChannelTypesForFamily(service.VideoAssetFamilySeedance) + // DoubaoVideo channels (official Volcengine Ark) also serve the native + // /api/v3 path; they are appended here instead of joining the seedance + // asset family so they stay out of the user asset binding system. + return append(service.VideoAssetChannelTypesForFamily(service.VideoAssetFamilySeedance), + constant.ChannelTypeDoubaoVideo) } return nil } diff --git a/middleware/doubao_asset_binding_test.go b/middleware/doubao_asset_binding_test.go index a495530..88f444f 100644 --- a/middleware/doubao_asset_binding_test.go +++ b/middleware/doubao_asset_binding_test.go @@ -212,8 +212,11 @@ func TestSeedanceTasksUseAllowedFamilyTypesInsteadOfModelPrefix(t *testing.T) { c.Request = httptest.NewRequest(http.MethodPost, "/api/v3/contents/generations/tasks", strings.NewReader(`{}`)) require.Equal(t, 0, requiredChannelTypeForRequest(c, "Doubao-Seedance-2.0")) + // The native /api/v3 path also accepts official Volcengine (DoubaoVideo) + // channels alongside the seedance asset family. require.ElementsMatch(t, - service.VideoAssetChannelTypesForFamily(service.VideoAssetFamilySeedance), + append(service.VideoAssetChannelTypesForFamily(service.VideoAssetFamilySeedance), + constant.ChannelTypeDoubaoVideo), allowedChannelTypesForRequest(c, "Doubao-Seedance-2.0"), ) } diff --git a/relay/channel/task/doubao/adaptor.go b/relay/channel/task/doubao/adaptor.go index 7f1af48..fcb0608 100644 --- a/relay/channel/task/doubao/adaptor.go +++ b/relay/channel/task/doubao/adaptor.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "strconv" + "strings" "time" "github.com/QuantumNous/new-api/common" @@ -109,7 +110,14 @@ func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { // ValidateRequestAndSetAction parses body, validates fields and sets default action. func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) { - // Accept only POST /v1/video/generations as "generate" action. + // The native /api/v3/contents/generations/tasks path pre-parses the + // Volcengine-native body and stores it in the context; reuse it instead of + // re-parsing the body as TaskSubmitReq (whose prompt validation would fail, + // since the native prompt lives inside content[].text). + if _, err := relaycommon.GetTaskRequest(c); err == nil { + info.Action = constant.TaskActionGenerate + return nil + } return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate) } @@ -245,11 +253,16 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (* r.Duration = lo.ToPtr(dto.IntValue(sec)) } - r.Content = lo.Reject(r.Content, func(c ContentItem, _ int) bool { return c.Type == "text" }) - r.Content = append(r.Content, ContentItem{ - Type: "text", - Text: req.Prompt, - }) + // An explicit prompt replaces any text item from metadata. An empty prompt + // only happens on the native /api/v3 path, where the prompt already lives + // in a content text item that must be preserved as-is. + if strings.TrimSpace(req.Prompt) != "" { + r.Content = lo.Reject(r.Content, func(c ContentItem, _ int) bool { return c.Type == "text" }) + r.Content = append(r.Content, ContentItem{ + Type: "text", + Text: req.Prompt, + }) + } return &r, nil } diff --git a/relay/channel/task/doubao/adaptor_test.go b/relay/channel/task/doubao/adaptor_test.go index 1f6d1c3..9353818 100644 --- a/relay/channel/task/doubao/adaptor_test.go +++ b/relay/channel/task/doubao/adaptor_test.go @@ -145,6 +145,66 @@ func TestConvertToRequestPayload_PromptAppendedAfterMetadataAndReplacesMetadataT require.Equal(t, "current prompt", payload.Content[1].Text) } +// Native /api/v3/contents/generations/tasks requests store the prompt inside a +// content text item while req.Prompt stays empty; such text items must survive. +func TestConvertToRequestPayload_EmptyPromptKeepsNativeContentText(t *testing.T) { + adaptor := &TaskAdaptor{} + req := &relaycommon.TaskSubmitReq{ + Model: "doubao-seedance-2-0-260128", + Metadata: map[string]interface{}{ + "content": []interface{}{ + map[string]interface{}{ + "type": "text", + "text": "一只猫在打哈欠", + }, + map[string]interface{}{ + "type": "image_url", + "image_url": map[string]interface{}{ + "url": "https://example.test/cat.png", + }, + }, + }, + "resolution": "1080p", + "ratio": "16:9", + }, + } + + payload, err := adaptor.convertToRequestPayload(req) + + require.NoError(t, err) + require.Len(t, payload.Content, 2) + require.Equal(t, "text", payload.Content[0].Type) + require.Equal(t, "一只猫在打哈欠", payload.Content[0].Text) + require.Equal(t, "image_url", payload.Content[1].Type) + require.Equal(t, "https://example.test/cat.png", payload.Content[1].ImageURL.URL) + require.Equal(t, "1080p", payload.Resolution) + require.Equal(t, "16:9", payload.Ratio) +} + +// Image-to-video without any text item must not produce an empty text item. +func TestConvertToRequestPayload_EmptyPromptWithoutTextContentAppendsNothing(t *testing.T) { + adaptor := &TaskAdaptor{} + req := &relaycommon.TaskSubmitReq{ + Model: "doubao-seedance-2-0-260128", + Metadata: map[string]interface{}{ + "content": []interface{}{ + map[string]interface{}{ + "type": "image_url", + "image_url": map[string]interface{}{ + "url": "https://example.test/cat.png", + }, + }, + }, + }, + } + + payload, err := adaptor.convertToRequestPayload(req) + + require.NoError(t, err) + require.Len(t, payload.Content, 1) + require.Equal(t, "image_url", payload.Content[0].Type) +} + func TestParseTaskResult_FailedUsesUpstreamErrorMessage(t *testing.T) { adaptor := &TaskAdaptor{} taskInfo, err := adaptor.ParseTaskResult([]byte(`{