From 12686067c5c0a2d3120ff8a2c0d363780c62ddb0 Mon Sep 17 00:00:00 2001 From: fengsilin Date: Wed, 19 Aug 2026 14:39:36 +0800 Subject: [PATCH] feat(volcengine): serve native /api/v3 video path on official DoubaoVideo channels The native /api/v3/contents/generations/tasks route was restricted to the seedance asset channel family (58/60/61); official Volcengine Ark channels (DoubaoVideo, type 54) could only be reached via the standardized /v1/video/generations path. Allow type 54 on the native route in both the distributor and the task retry channel selection, while keeping it out of the seedance asset binding system. The taskdoubao adaptor now reuses a pre-parsed task request from the context (native path) instead of re-parsing the body as TaskSubmitReq, whose prompt validation would reject the Volcengine-native format. When req.Prompt is empty (native path), content text items from metadata are preserved instead of being replaced with an empty prompt; the standardized path behavior (prompt replaces metadata text) is unchanged and covered by existing tests. Co-Authored-By: ZCode --- controller/channel_test_tianyiyun_test.go | 5 +- controller/relay.go | 4 +- middleware/distributor.go | 6 ++- middleware/doubao_asset_binding_test.go | 5 +- relay/channel/task/doubao/adaptor.go | 25 +++++++--- relay/channel/task/doubao/adaptor_test.go | 60 +++++++++++++++++++++++ 6 files changed, 95 insertions(+), 10 deletions(-) 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(`{