Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions common/gin.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (

const KeyRequestBody = "key_request_body"
const KeyBodyStorage = "key_body_storage"
const KeySeedanceOfficialAPI = "seedance_official_api"

var ErrRequestBodyTooLarge = errors.New("request body too large")

Expand Down
24 changes: 24 additions & 0 deletions controller/seedance.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package controller

import (
"net/http"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/relay"
"github.com/gin-gonic/gin"
)

func RelaySeedanceTask(c *gin.Context) {
c.Set(common.KeySeedanceOfficialAPI, true)
RelayTask(c)
}

func RelaySeedanceTaskFetch(c *gin.Context) {
c.Set(common.KeySeedanceOfficialAPI, true)
respBody, taskErr := relay.SeedanceTaskFetch(c)
if taskErr != nil {
respondTaskError(c, taskErr)
return
}
c.Data(http.StatusOK, "application/json", respBody)
}
23 changes: 23 additions & 0 deletions middleware/seedance_adapter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package middleware

import (
"net/http"

"github.com/QuantumNous/new-api/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"

"github.com/gin-gonic/gin"
)

func SeedanceRequestConvert() func(c *gin.Context) {
return func(c *gin.Context) {
c.Set(common.KeySeedanceOfficialAPI, true)

if c.Request.Method == http.MethodPost {
c.Request.URL.Path = "/v1/video/generations"
c.Set("relay_mode", relayconstant.RelayModeVideoSubmit)
}

c.Next()
}
}
65 changes: 65 additions & 0 deletions middleware/seedance_adapter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package middleware

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/QuantumNous/new-api/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)

func TestSeedanceRequestConvertSubmit(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(SeedanceRequestConvert())
router.POST("/seedance/api/v3/contents/generations/tasks", func(c *gin.Context) {
require.True(t, c.GetBool(common.KeySeedanceOfficialAPI))
require.Equal(t, "/v1/video/generations", c.Request.URL.Path)
require.Equal(t, relayconstant.RelayModeVideoSubmit, c.GetInt("relay_mode"))
c.Status(http.StatusNoContent)
})

req := httptest.NewRequest(http.MethodPost, "/seedance/api/v3/contents/generations/tasks", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)

require.Equal(t, http.StatusNoContent, recorder.Code)
}

func TestSeedanceRequestConvertFetchByID(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(SeedanceRequestConvert())
router.GET("/seedance/api/v3/contents/generations/tasks/:task_id", func(c *gin.Context) {
require.True(t, c.GetBool(common.KeySeedanceOfficialAPI))
require.Equal(t, "/seedance/api/v3/contents/generations/tasks/task_public", c.Request.URL.Path)
require.Equal(t, "task_public", c.Param("task_id"))
c.Status(http.StatusNoContent)
})

req := httptest.NewRequest(http.MethodGet, "/seedance/api/v3/contents/generations/tasks/task_public", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)

require.Equal(t, http.StatusNoContent, recorder.Code)
}

func TestSeedanceRequestConvertFetchList(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(SeedanceRequestConvert())
router.GET("/seedance/api/v3/contents/generations/tasks", func(c *gin.Context) {
require.True(t, c.GetBool(common.KeySeedanceOfficialAPI))
require.Equal(t, "/seedance/api/v3/contents/generations/tasks", c.Request.URL.Path)
c.Status(http.StatusNoContent)
})

req := httptest.NewRequest(http.MethodGet, "/seedance/api/v3/contents/generations/tasks", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)

require.Equal(t, http.StatusNoContent, recorder.Code)
}
80 changes: 80 additions & 0 deletions relay/channel/task/doubao/adaptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"io"
"net/http"
"strconv"
"strings"
"time"

"github.com/QuantumNous/new-api/common"
Expand Down Expand Up @@ -115,6 +116,29 @@ 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) {
if c.GetBool(common.KeySeedanceOfficialAPI) {
var body map[string]interface{}
if err := common.UnmarshalBodyReusable(c, &body); err != nil {
return service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest)
}

modelName, _ := body["model"].(string)
if strings.TrimSpace(modelName) == "" {
return service.TaskErrorWrapperLocal(fmt.Errorf("field model is required"), "missing_model", http.StatusBadRequest)
}
if _, ok := body["content"]; !ok {
return service.TaskErrorWrapperLocal(fmt.Errorf("field content is required"), "missing_content", http.StatusBadRequest)
}

info.Action = constant.TaskActionGenerate
c.Set("task_request", relaycommon.TaskSubmitReq{
Model: modelName,
Prompt: seedanceTextPrompt(body),
Metadata: body,
})
return nil
}

// Accept only POST /v1/video/generations as "generate" action.
return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate)
}
Expand Down Expand Up @@ -177,6 +201,30 @@ func hasVideoInMetadata(metadata map[string]interface{}) bool {

// BuildRequestBody converts request into Doubao specific format.
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
if c.GetBool(common.KeySeedanceOfficialAPI) {
storage, err := common.GetBodyStorage(c)
if err != nil {
return nil, err
}
cachedBody, err := storage.Bytes()
if err != nil {
return nil, err
}

var bodyMap map[string]interface{}
if err := common.Unmarshal(cachedBody, &bodyMap); err != nil {
return bytes.NewReader(cachedBody), nil
}
if info.UpstreamModelName != "" {
bodyMap["model"] = info.UpstreamModelName
}
data, err := common.Marshal(bodyMap)
if err != nil {
return nil, err
}
return bytes.NewReader(data), nil
}

Comment on lines +204 to +227

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find where UpstreamModelName / OriginModelName are populated in the task relay flow
rg -nP '\b(UpstreamModelName|OriginModelName)\s*=' --type=go -g '!**/*_test.go' relay/
# Inspect how TaskSubmitReq.Model feeds into RelayInfo
rg -nP -C3 '\bTaskSubmitReq\b' --type=go relay/common/

Repository: QuantumNous/new-api

Length of output: 7991


🏁 Script executed:

sed -n '119,145p' relay/channel/task/doubao/adaptor.go

Repository: QuantumNous/new-api

Length of output: 1116


🏁 Script executed:

sed -n '204,230p' relay/channel/task/doubao/adaptor.go

Repository: QuantumNous/new-api

Length of output: 799


Ensure info.UpstreamModelName is populated for Seedance billing attribution.

In the Seedance branch (BuildRequestBody), info.UpstreamModelName is only used to override the request model if it has already been set (e.g., by model mapping). However, if no mapping occurred, info.UpstreamModelName remains empty despite the model being present in the request body.

While the code correctly omits the override in this case, relying solely on implicit behavior risks missing the upstream model name for billing and usage tracking if it hasn't been populated elsewhere. It is safer to explicitly extract and assign the model from the body to info if it is currently unset.

Consider adding the following logic before modifying bodyMap:

if info.UpstreamModelName == "" {
    if m, ok := bodyMap["model"].(string); ok && m != "" {
        info.UpstreamModelName = m
    }
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@relay/channel/task/doubao/adaptor.go` around lines 204 - 227, In
BuildRequestBody’s Seedance official API branch, info.UpstreamModelName is only
used when already populated, so it can stay empty even though the request body
contains the model. Before rewriting bodyMap, explicitly read bodyMap["model"]
and assign it to info.UpstreamModelName when it is currently unset, then
continue with the existing override/Marshal flow so billing attribution always
has the upstream model name.

req, err := relaycommon.GetTaskRequest(c)
if err != nil {
return nil, err
Expand Down Expand Up @@ -224,6 +272,13 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela
return
}

if c.GetBool(common.KeySeedanceOfficialAPI) {
c.JSON(http.StatusOK, gin.H{
"id": dResp.ID,
})
return dResp.ID, responseBody, nil
}

ov := dto.NewOpenAIVideo()
ov.ID = info.PublicTaskID
ov.TaskID = info.PublicTaskID
Expand Down Expand Up @@ -303,6 +358,27 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*
return &r, nil
}

func seedanceTextPrompt(body map[string]interface{}) string {
content, ok := body["content"].([]interface{})
if !ok {
return ""
}
for _, item := range content {
itemMap, ok := item.(map[string]interface{})
if !ok {
continue
}
if itemMap["type"] != "text" {
continue
}
text, _ := itemMap["text"].(string)
if strings.TrimSpace(text) != "" {
return text
}
}
return ""
}

func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) {
resTask := responseTask{}
if err := common.Unmarshal(respBody, &resTask); err != nil {
Expand Down Expand Up @@ -332,6 +408,10 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e
taskResult.Status = model.TaskStatusFailure
taskResult.Progress = "100%"
taskResult.Reason = resTask.Error.Message
case "expired", "cancelled":
taskResult.Status = model.TaskStatusFailure
taskResult.Progress = "100%"
taskResult.Reason = resTask.Status
default:
// Unknown status, treat as processing
taskResult.Status = model.TaskStatusInProgress
Expand Down
78 changes: 78 additions & 0 deletions relay/channel/task/doubao/adaptor_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package doubao

import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"testing"

"github.com/QuantumNous/new-api/common"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestSeedanceOfficialBuildRequestBodyPreservesNativeContent(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Set(common.KeySeedanceOfficialAPI, true)
ctx.Request = httptest.NewRequest(http.MethodPost, "/seedance/api/v3/contents/generations/tasks", bytes.NewBufferString(`{
"model":"doubao-seedance-1-5-pro",
"content":[
{"type":"image_url","image_url":{"url":"https://example.com/a.png"},"role":"first_frame"},
{"type":"text","text":"make a video"}
],
"duration":5,
"watermark":false
}`))
ctx.Request.Header.Set("Content-Type", "application/json")

adaptor := &TaskAdaptor{}
info := &relaycommon.RelayInfo{
ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "upstream-seedance-model"},
}

body, err := adaptor.BuildRequestBody(ctx, info)
require.NoError(t, err)

raw, err := io.ReadAll(body)
require.NoError(t, err)

var payload map[string]any
require.NoError(t, common.Unmarshal(raw, &payload))
assert.Equal(t, "upstream-seedance-model", payload["model"])
assert.Equal(t, float64(5), payload["duration"])
assert.Equal(t, false, payload["watermark"])

content, ok := payload["content"].([]any)
require.True(t, ok)
require.Len(t, content, 2)
first, ok := content[0].(map[string]any)
require.True(t, ok)
assert.Equal(t, "image_url", first["type"])
assert.Equal(t, "first_frame", first["role"])
}

func TestSeedanceOfficialDoResponseReturnsUpstreamTaskID(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Set(common.KeySeedanceOfficialAPI, true)

adaptor := &TaskAdaptor{}
resp := &http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"id":"cgt-upstream"}`)),
}
info := &relaycommon.RelayInfo{
TaskRelayInfo: &relaycommon.TaskRelayInfo{PublicTaskID: "task_public"},
}

taskID, taskData, taskErr := adaptor.DoResponse(ctx, resp, info)
require.Nil(t, taskErr)
assert.Equal(t, "cgt-upstream", taskID)
assert.JSONEq(t, `{"id":"cgt-upstream"}`, string(taskData))
assert.JSONEq(t, `{"id":"cgt-upstream"}`, recorder.Body.String())
}
54 changes: 54 additions & 0 deletions relay/relay_task_seedance_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package relay

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestSeedanceTaskIDFilters(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(
http.MethodGet,
"/seedance/api/v3/contents/generations/tasks?filter.task_ids=cgt-a,cgt-b&filter.task_ids=task_c&filter.task_ids[]=cgt-d",
nil,
)

require.Equal(t, []string{"cgt-a", "cgt-b", "task_c", "cgt-d"}, seedanceTaskIDFilters(ctx))
}

func TestSeedanceTaskResponseUsesUpstreamShape(t *testing.T) {
task := &model.Task{
TaskID: "task_public",
Status: model.TaskStatusSuccess,
SubmitTime: 1710000000,
UpdatedAt: 1710000100,
Properties: model.Properties{
OriginModelName: "doubao-seedance-1-5-pro",
},
PrivateData: model.TaskPrivateData{
UpstreamTaskID: "cgt-upstream",
ResultURL: "https://example.com/video.mp4",
},
Data: json.RawMessage(`{"id":"cgt-upstream","status":"running","content":{},"service_tier":"default"}`),
}

resp := seedanceTaskResponse(task)
assert.Equal(t, "cgt-upstream", resp["id"])
assert.Equal(t, "doubao-seedance-1-5-pro", resp["model"])
assert.Equal(t, "succeeded", resp["status"])
assert.Equal(t, int64(1710000000), resp["created_at"])
assert.Equal(t, int64(1710000100), resp["updated_at"])

content, ok := resp["content"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "https://example.com/video.mp4", content["video_url"])
}
Loading
Loading