Przeglądaj źródła

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 <noreply@anthropic.com>
master
fengsilin 1 tydzień temu
rodzic
commit
12686067c5
6 zmienionych plików z 95 dodań i 10 usunięć
  1. +4
    -1
      controller/channel_test_tianyiyun_test.go
  2. +3
    -1
      controller/relay.go
  3. +5
    -1
      middleware/distributor.go
  4. +4
    -1
      middleware/doubao_asset_binding_test.go
  5. +19
    -6
      relay/channel/task/doubao/adaptor.go
  6. +60
    -0
      relay/channel/task/doubao/adaptor_test.go

+ 4
- 1
controller/channel_test_tianyiyun_test.go Wyświetl plik

@@ -29,8 +29,11 @@ func TestRequiredTaskChannelTypeForTianyiYunSeedanceModelUsesAllowedFamily(t *te
c := newControllerJSONContext(t, "/api/v3/contents/generations/tasks", `{"model":"Doubao-Seedance-2.0"}`) c := newControllerJSONContext(t, "/api/v3/contents/generations/tasks", `{"model":"Doubao-Seedance-2.0"}`)


require.Equal(t, 0, requiredTaskChannelTypeForRequest(c)) 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, require.ElementsMatch(t,
service.VideoAssetChannelTypesForFamily(service.VideoAssetFamilySeedance),
append(service.VideoAssetChannelTypesForFamily(service.VideoAssetFamilySeedance),
constant.ChannelTypeDoubaoVideo),
allowedTaskChannelTypesForRequest(c), allowedTaskChannelTypesForRequest(c),
) )
} }

+ 3
- 1
controller/relay.go Wyświetl plik

@@ -367,7 +367,9 @@ func requiredTaskChannelTypeForRequest(c *gin.Context) int {


func allowedTaskChannelTypesForRequest(c *gin.Context) []int { func allowedTaskChannelTypesForRequest(c *gin.Context) []int {
if strings.HasPrefix(c.Request.URL.Path, "/api/v3/contents/generations/tasks") { 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) { if isKlingAipingNativePath(c.Request.URL.Path) {
return service.VideoAssetChannelTypesForFamily(service.VideoAssetFamilyKling) return service.VideoAssetChannelTypesForFamily(service.VideoAssetFamilyKling)


+ 5
- 1
middleware/distributor.go Wyświetl plik

@@ -345,7 +345,11 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {


func allowedChannelTypesForRequest(c *gin.Context, modelName string) []int { func allowedChannelTypesForRequest(c *gin.Context, modelName string) []int {
if strings.HasPrefix(c.Request.URL.Path, "/api/v3/contents/generations/tasks") { 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 return nil
} }


+ 4
- 1
middleware/doubao_asset_binding_test.go Wyświetl plik

@@ -212,8 +212,11 @@ func TestSeedanceTasksUseAllowedFamilyTypesInsteadOfModelPrefix(t *testing.T) {
c.Request = httptest.NewRequest(http.MethodPost, "/api/v3/contents/generations/tasks", strings.NewReader(`{}`)) c.Request = httptest.NewRequest(http.MethodPost, "/api/v3/contents/generations/tasks", strings.NewReader(`{}`))


require.Equal(t, 0, requiredChannelTypeForRequest(c, "Doubao-Seedance-2.0")) 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, require.ElementsMatch(t,
service.VideoAssetChannelTypesForFamily(service.VideoAssetFamilySeedance),
append(service.VideoAssetChannelTypesForFamily(service.VideoAssetFamilySeedance),
constant.ChannelTypeDoubaoVideo),
allowedChannelTypesForRequest(c, "Doubao-Seedance-2.0"), allowedChannelTypesForRequest(c, "Doubao-Seedance-2.0"),
) )
} }


+ 19
- 6
relay/channel/task/doubao/adaptor.go Wyświetl plik

@@ -6,6 +6,7 @@ import (
"io" "io"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"


"github.com/QuantumNous/new-api/common" "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. // ValidateRequestAndSetAction parses body, validates fields and sets default action.
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) { 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) 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.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 return &r, nil
} }


+ 60
- 0
relay/channel/task/doubao/adaptor_test.go Wyświetl plik

@@ -145,6 +145,66 @@ func TestConvertToRequestPayload_PromptAppendedAfterMetadataAndReplacesMetadataT
require.Equal(t, "current prompt", payload.Content[1].Text) 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) { func TestParseTaskResult_FailedUsesUpstreamErrorMessage(t *testing.T) {
adaptor := &TaskAdaptor{} adaptor := &TaskAdaptor{}
taskInfo, err := adaptor.ParseTaskResult([]byte(`{ taskInfo, err := adaptor.ParseTaskResult([]byte(`{


Ładowanie…
Anuluj
Zapisz