diff --git a/middleware/doubao_adapter.go b/middleware/doubao_adapter.go new file mode 100644 index 000000000000..05a000287084 --- /dev/null +++ b/middleware/doubao_adapter.go @@ -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 +} diff --git a/relay/channel/adapter.go b/relay/channel/adapter.go index d2f7c6bb6d5a..1158c4925f77 100644 --- a/relay/channel/adapter.go +++ b/relay/channel/adapter.go @@ -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) +} diff --git a/relay/channel/task/doubao/adaptor.go b/relay/channel/task/doubao/adaptor.go index a6dabb5f1086..c51d2a57e073 100644 --- a/relay/channel/task/doubao/adaptor.go +++ b/relay/channel/task/doubao/adaptor.go @@ -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" + } +} diff --git a/relay/channel/task/sora/adaptor.go b/relay/channel/task/sora/adaptor.go index e9029aa20d46..5c71d381a4f1 100644 --- a/relay/channel/task/sora/adaptor.go +++ b/relay/channel/task/sora/adaptor.go @@ -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 { @@ -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 } diff --git a/relay/common/relay_utils.go b/relay/common/relay_utils.go index 18df77a645d6..1f4f3572f3b8 100644 --- a/relay/common/relay_utils.go +++ b/relay/common/relay_utils.go @@ -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 中设置 } diff --git a/relay/relay_task.go b/relay/relay_task.go index 098e23828b6c..4f422c619cbb 100644 --- a/relay/relay_task.go +++ b/relay/relay_task.go @@ -384,6 +384,33 @@ func videoFetchByIDRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *d return } + // Doubao native route: return Doubao-native format so downstream new-api + // instances (using the "doubao-video" channel type) can parse the polling + // response with their own ParseTaskResult method. + if c.GetBool("doubao_native_route") { + adaptor := GetTaskAdaptor(originTask.Platform) + if adaptor != nil { + if converter, ok := adaptor.(channel.DoubaoNativeResponseConverter); ok { + nativeData, convertErr := converter.ConvertToDoubaoNativeResponse(originTask) + if convertErr != nil { + taskResp = service.TaskErrorWrapper(convertErr, "convert_to_doubao_native_failed", http.StatusInternalServerError) + return + } + respBody = nativeData + return + } + // Fallback: if the platform doesn't implement DoubaoNativeResponseConverter, + // return OpenAI Video API format as the next best option. + if converter, ok := adaptor.(channel.OpenAIVideoConverter); ok { + openAIVideoData, convertErr := converter.ConvertToOpenAIVideo(originTask) + if convertErr == nil { + respBody = openAIVideoData + return + } + } + } + } + // OpenAI Video API 格式: 走各 adaptor 的 ConvertToOpenAIVideo if isOpenAIVideoAPI { adaptor := GetTaskAdaptor(originTask.Platform) diff --git a/router/video-router.go b/router/video-router.go index 461451104520..f838443e81e6 100644 --- a/router/video-router.go +++ b/router/video-router.go @@ -49,4 +49,16 @@ func SetVideoRouter(router *gin.Engine) { // Maps to: /?Action=CVSync2AsyncSubmitTask&Version=2022-08-31 and /?Action=CVSync2AsyncGetResult&Version=2022-08-31 jimengOfficialGroup.POST("/", controller.RelayTask) } + + // Doubao video native API routes — allows downstream to call us using the + // official Doubao video API path format directly. + // POST /api/v3/contents/generations/tasks → create video task + // GET /api/v3/contents/generations/tasks/:task_id → fetch task status + doubaoVideoGroup := router.Group("/api/v3/contents/generations") + doubaoVideoGroup.Use(middleware.RouteTag("relay")) + doubaoVideoGroup.Use(middleware.DoubaoRequestConvert(), middleware.TokenAuth(), middleware.Distribute()) + { + doubaoVideoGroup.POST("/tasks", controller.RelayTask) + doubaoVideoGroup.GET("/tasks/:task_id", controller.RelayTaskFetch) + } }