Skip to content
245 changes: 245 additions & 0 deletions middleware/doubao_adapter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
package middleware

import (
"bytes"
"io"
"net/http"
"strconv"
"strings"

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

// doubaoContentItem mirrors the "content" array items from the Doubao native API.
type doubaoContentItem struct {
Type string `json:"type,omitempty"`
Text string `json:"text,omitempty"`
ImageURL *doubaoMediaURL `json:"image_url,omitempty"`
VideoURL *doubaoMediaURL `json:"video_url,omitempty"`
AudioURL *doubaoMediaURL `json:"audio_url,omitempty"`
}

type doubaoMediaURL struct {
URL string `json:"url,omitempty"`
}

// doubaoNativeRequest is the native Doubao video creation request body.
// POST /api/v3/contents/generations/tasks
type doubaoNativeRequest struct {
Model string `json:"model"`
Content []doubaoContentItem `json:"content,omitempty"`
Resolution string `json:"resolution,omitempty"`
Ratio string `json:"ratio,omitempty"`
// Duration and Seed can be int or object {"value": N} from upstream.
// We accept any JSON here and re-pack them into metadata.
Duration interface{} `json:"duration,omitempty"`
Seed interface{} `json:"seed,omitempty"`
CameraFixed interface{} `json:"camera_fixed,omitempty"`
Watermark interface{} `json:"watermark,omitempty"`
// Pass-through extra fields
CallbackURL string `json:"callback_url,omitempty"`
ReturnLastFrame interface{} `json:"return_last_frame,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
ExecutionExpiresAfter interface{} `json:"execution_expires_after,omitempty"`
GenerateAudio interface{} `json:"generate_audio,omitempty"`
Draft interface{} `json:"draft,omitempty"`
Frames interface{} `json:"frames,omitempty"`
Tools interface{} `json:"tools,omitempty"`
}

// DoubaoRequestConvert converts native Doubao video API requests into new-api's
// internal task format so they can be handled by the existing task relay pipeline.
//
// Supported paths:
// POST /api/v3/contents/generations/tasks → create task
// GET /api/v3/contents/generations/tasks/:task_id → fetch task
func DoubaoRequestConvert() func(c *gin.Context) {
return func(c *gin.Context) {
// Determine if this is a fetch request (GET with task_id)
if c.Request.Method == http.MethodGet {
taskID := c.Param("task_id")
if taskID == "" {
abortWithOpenAiMessage(c, http.StatusBadRequest, "task_id is required")
return
}
// Route to internal fetch endpoint.
// Set doubao_native_route so that videoFetchByIDRespBodyBuilder
// returns Doubao-native format, which downstream ParseTaskResult can parse.
c.Request.URL.Path = "/v1/video/generations/" + taskID
c.Set("task_id", taskID)
c.Set("relay_mode", relayconstant.RelayModeVideoFetchByID)
c.Set("doubao_native_route", true)
c.Next()
return
}

// POST: parse native Doubao request and convert to internal format
var nativeReq doubaoNativeRequest
if err := common.UnmarshalBodyReusable(c, &nativeReq); err != nil {
abortWithOpenAiMessage(c, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}

// Extract prompt and images from the content array
var promptParts []string
var images []string

for _, item := range nativeReq.Content {
switch item.Type {
case "text":
if t := strings.TrimSpace(item.Text); t != "" {
promptParts = append(promptParts, t)
}
case "image_url":
if item.ImageURL != nil && item.ImageURL.URL != "" {
images = append(images, item.ImageURL.URL)
}
}
}
prompt := strings.Join(promptParts, "\n")

// Build metadata — carry all non-standard fields so the doubao adaptor
// can pick them up via taskcommon.UnmarshalMetadata.
metadata := make(map[string]interface{})

// Re-pack the full original content array so hasVideoInMetadata() works
// correctly (doubao adaptor checks metadata["content"]).
if len(nativeReq.Content) > 0 {
rawContent := make([]interface{}, len(nativeReq.Content))
for i, item := range nativeReq.Content {
m := make(map[string]interface{})
if item.Type != "" {
m["type"] = item.Type
}
if item.Text != "" {
m["text"] = item.Text
}
if item.ImageURL != nil {
m["image_url"] = map[string]interface{}{"url": item.ImageURL.URL}
}
if item.VideoURL != nil {
m["video_url"] = map[string]interface{}{"url": item.VideoURL.URL}
}
if item.AudioURL != nil {
m["audio_url"] = map[string]interface{}{"url": item.AudioURL.URL}
}
rawContent[i] = m
}
metadata["content"] = rawContent
}

if nativeReq.Resolution != "" {
metadata["resolution"] = nativeReq.Resolution
}
if nativeReq.Ratio != "" {
metadata["ratio"] = nativeReq.Ratio
}
if nativeReq.Duration != nil {
metadata["duration"] = normalizeDuration(nativeReq.Duration)
}
if nativeReq.Seed != nil {
metadata["seed"] = nativeReq.Seed
}
if nativeReq.CameraFixed != nil {
metadata["camera_fixed"] = nativeReq.CameraFixed
}
if nativeReq.Watermark != nil {
metadata["watermark"] = nativeReq.Watermark
}
if nativeReq.CallbackURL != "" {
metadata["callback_url"] = nativeReq.CallbackURL
}
if nativeReq.ReturnLastFrame != nil {
metadata["return_last_frame"] = nativeReq.ReturnLastFrame
}
if nativeReq.ServiceTier != "" {
metadata["service_tier"] = nativeReq.ServiceTier
}
if nativeReq.ExecutionExpiresAfter != nil {
metadata["execution_expires_after"] = nativeReq.ExecutionExpiresAfter
}
if nativeReq.GenerateAudio != nil {
metadata["generate_audio"] = nativeReq.GenerateAudio
}
if nativeReq.Draft != nil {
metadata["draft"] = nativeReq.Draft
}
if nativeReq.Frames != nil {
metadata["frames"] = nativeReq.Frames
}
if nativeReq.Tools != nil {
metadata["tools"] = nativeReq.Tools
}

// Build unified internal request
unifiedReq := map[string]interface{}{
"model": nativeReq.Model,
"prompt": prompt,
"metadata": metadata,
}

if len(images) == 1 {
unifiedReq["image"] = images[0]
} else if len(images) > 1 {
unifiedReq["images"] = images
}

// Extract seconds/duration for billing estimation
if nativeReq.Duration != nil {
if secs := extractDurationSeconds(nativeReq.Duration); secs > 0 {
unifiedReq["seconds"] = strconv.Itoa(secs)
}
}

jsonData, err := common.Marshal(unifiedReq)
if err != nil {
abortWithOpenAiMessage(c, http.StatusInternalServerError, "failed to marshal request: "+err.Error())
return
}

// Replace request body — also clear the BodyStorage cache so that
// downstream handlers (e.g. ValidateBasicTaskRequest) read the new body
// instead of the cached original Doubao native payload.
c.Request.Body = io.NopCloser(bytes.NewBuffer(jsonData))
c.Set(common.KeyRequestBody, jsonData)
c.Set(common.KeyBodyStorage, nil)

// Redirect to the internal video generation endpoint
c.Request.URL.Path = "/v1/video/generations"

c.Next()
}
}

// normalizeDuration converts various duration representations to a plain int.
// Doubao native API accepts both {"value": N} and plain N.
func normalizeDuration(v interface{}) interface{} {
switch d := v.(type) {
case float64:
return int(d)
case int:
return d
case map[string]interface{}:
if val, ok := d["value"]; ok {
return normalizeDuration(val)
}
}
return v
}

// extractDurationSeconds returns the integer second value from a duration field.
func extractDurationSeconds(v interface{}) int {
switch d := v.(type) {
case float64:
return int(d)
case int:
return d
case map[string]interface{}:
if val, ok := d["value"]; ok {
return extractDurationSeconds(val)
}
}
return 0
}
8 changes: 8 additions & 0 deletions relay/channel/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,11 @@ type TaskAdaptor interface {
type OpenAIVideoConverter interface {
ConvertToOpenAIVideo(originTask *model.Task) ([]byte, error)
}

// DoubaoNativeResponseConverter is implemented by adaptors that can convert an
// internal Task record back into the Doubao-native API response format, so that
// a downstream new-api instance using the "doubao-video" channel type can parse
// the polling response with its own ParseTaskResult method.
type DoubaoNativeResponseConverter interface {
ConvertToDoubaoNativeResponse(originTask *model.Task) ([]byte, error)
}
74 changes: 74 additions & 0 deletions relay/channel/task/doubao/adaptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -366,3 +366,77 @@ func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, erro

return common.Marshal(openAIVideo)
}

// ConvertToDoubaoNativeResponse converts an internal Task record back to the
// Doubao-native API response format. This is used when a downstream new-api
// instance (configured with the "doubao-video" channel type pointing to us)
// polls for task status via GET /api/v3/contents/generations/tasks/:task_id.
// The downstream's ParseTaskResult expects this exact format.
func (a *TaskAdaptor) ConvertToDoubaoNativeResponse(originTask *model.Task) ([]byte, error) {
// Try to reconstruct from stored upstream response first
var dResp responseTask
if len(originTask.Data) > 0 {
_ = common.Unmarshal(originTask.Data, &dResp)
}

// If the upstream task_id is stored, use it; otherwise fall back to public ID
upstreamID := originTask.GetUpstreamTaskID()
if upstreamID == "" {
upstreamID = originTask.TaskID
}

// Map internal status back to Doubao native status strings
doubaoStatus := internalStatusToDoubaoStatus(originTask.Status)

// Build a Doubao-native response payload
native := map[string]interface{}{
"id": upstreamID,
"model": originTask.Properties.OriginModelName,
"status": doubaoStatus,
}

if doubaoStatus == "succeeded" {
videoURL := originTask.GetResultURL()
if videoURL == "" {
videoURL = dResp.Content.VideoURL
}
native["content"] = map[string]interface{}{
"video_url": videoURL,
}
}

if doubaoStatus == "failed" {
native["error"] = map[string]interface{}{
"code": dResp.Error.Code,
"message": dResp.Error.Message,
}
}

if dResp.Usage.TotalTokens > 0 {
native["usage"] = dResp.Usage
}
if originTask.CreatedAt > 0 {
native["created_at"] = originTask.CreatedAt
}
if originTask.UpdatedAt > 0 {
native["updated_at"] = originTask.UpdatedAt
}

return common.Marshal(native)
}

// internalStatusToDoubaoStatus maps internal task status to Doubao native status strings.
func internalStatusToDoubaoStatus(status model.TaskStatus) string {
switch status {
case model.TaskStatusQueued, model.TaskStatusSubmitted, model.TaskStatusNotStart:
return "pending"
case model.TaskStatusInProgress:
return "processing"
case model.TaskStatusSuccess:
return "succeeded"
case model.TaskStatusFailure:
return "failed"
default:
return "pending"
}
}
32 changes: 28 additions & 4 deletions relay/channel/task/sora/adaptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,16 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
}

// EstimateBilling 根据用户请求的 seconds 和 size 计算 OtherRatios。
//
// Sora 官方定价(按秒计费):
//
// sora-2 720p (720x1280 / 1280x720) $0.10/s
// sora-2-pro 720p (720x1280 / 1280x720) $0.30/s → size ratio = 1.0 (sora-2-pro 基准)
// sora-2-pro 1024p (1024x1792 / 1792x1024) $0.50/s → size ratio = 5/3 ≈ 1.6667
// sora-2-pro 1080p (1080x1920 / 1920x1080) $0.70/s → size ratio = 7/3 ≈ 2.3333
//
// 管理员应分别为 sora-2 设置 $0.10/s 的基准价,为 sora-2-pro 设置 $0.30/s 的基准价(720p)。
// 系统会自动乘以 seconds 和 size ratio 得出最终费用。
func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInfo) map[string]float64 {
// remix 路径的 OtherRatios 已在 ResolveOriginTask 中设置
if info.Action == constant.TaskActionRemix {
Expand All @@ -119,12 +129,26 @@ func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInf
size = "720x1280"
}

model := info.OriginModelName

// size ratio 相对于各模型自身的 720p 基准价
// sora-2 只支持 720p → ratio = 1.0
// sora-2-pro 720p → ratio = 1.0, 1024p → ratio = 5/3 ≈ 1.6667, 1080p → ratio = 7/3 ≈ 2.3333
sizeRatio := 1.0
if strings.HasPrefix(model, "sora-2-pro") {
switch size {
case "1920x1080", "1080x1920":
sizeRatio = 7.0 / 3.0 // $0.70 / $0.30
case "1792x1024", "1024x1792":
sizeRatio = 5.0 / 3.0 // $0.50 / $0.30
default: // 720x1280, 1280x720
sizeRatio = 1.0
}
}

ratios := map[string]float64{
"seconds": float64(seconds),
"size": 1,
}
if size == "1792x1024" || size == "1024x1792" {
ratios["size"] = 1.666667
"size": sizeRatio,
}
return ratios
}
Expand Down
6 changes: 3 additions & 3 deletions relay/common/relay_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,10 +168,10 @@ func ValidateMultipartDirect(c *gin.Context, info *RelayInfo) *dto.TaskError {
}

if model == "sora-2" && !lo.Contains([]string{"720x1280", "1280x720"}, size) {
return createTaskError(fmt.Errorf("sora-2 size is invalid"), "invalid_size", http.StatusBadRequest, true)
return createTaskError(fmt.Errorf("sora-2 only supports 720p (720x1280 or 1280x720)"), "invalid_size", http.StatusBadRequest, true)
}
if model == "sora-2-pro" && !lo.Contains([]string{"720x1280", "1280x720", "1792x1024", "1024x1792"}, size) {
return createTaskError(fmt.Errorf("sora-2 size is invalid"), "invalid_size", http.StatusBadRequest, true)
if model == "sora-2-pro" && !lo.Contains([]string{"720x1280", "1280x720", "1792x1024", "1024x1792", "1920x1080", "1080x1920"}, size) {
return createTaskError(fmt.Errorf("sora-2-pro only supports 720p (720x1280/1280x720), 1024p (1024x1792/1792x1024) or 1080p (1080x1920/1920x1080)"), "invalid_size", http.StatusBadRequest, true)
}
// OtherRatios 已移到 Sora adaptor 的 EstimateBilling 中设置
}
Expand Down
Loading
Loading