From 75b527f545dab48577dab9d36e0a9e1bb6005871 Mon Sep 17 00:00:00 2001 From: nebulamao Date: Thu, 13 Aug 2026 15:07:06 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(hailuo):=20=E6=94=AF=E6=8C=81=20MiniMa?= =?UTF-8?q?x=20H3=20=E8=A7=86=E9=A2=91=E7=94=9F=E6=88=90=20V2=20=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 MiniMax-H3 模型,按模型名分流:H3 走 /v2/video_generation 与 /v2/query/video_generation/{task_id},其余 Hailuo 模型保持 V1 行为 - 多模态 content 输入:文生视频 / 图生视频(首帧、尾帧、首尾帧)/ 多模态参考生视频(metadata.content 透传,缺失非空 text 自动补 prompt) - V2 请求校验:duration [4,15]、resolution 768P/2K、ratio 白名单、 图片与参考媒体数量上限,计费倍率在转发前钳制 - 模型映射修复:校验阶段先解析渠道映射,轮询阶段按 task.Properties.UpstreamModelName 选择 V2 查询端点,缺失时回退 OriginModelName - 计费:EstimateBilling 按秒数 x 分辨率倍率(2K 为 768P 的 1.6 倍) - 测试:单元测试 + httptest 端到端(提交/轮询/转换链路、 service 轮询真实适配器场景) --- relay/channel/task/hailuo/adaptor.go | 436 +++++++++++++++++++++- relay/channel/task/hailuo/adaptor_test.go | 336 +++++++++++++++++ relay/channel/task/hailuo/constants.go | 39 ++ relay/channel/task/hailuo/e2e_test.go | 197 ++++++++++ relay/channel/task/hailuo/models.go | 74 ++++ service/hailuo_v2_e2e_test.go | 100 +++++ service/task_polling.go | 6 + 7 files changed, 1183 insertions(+), 5 deletions(-) create mode 100644 relay/channel/task/hailuo/adaptor_test.go create mode 100644 relay/channel/task/hailuo/e2e_test.go create mode 100644 service/hailuo_v2_e2e_test.go diff --git a/relay/channel/task/hailuo/adaptor.go b/relay/channel/task/hailuo/adaptor.go index af9f5c57c52a..91e4e859b70a 100644 --- a/relay/channel/task/hailuo/adaptor.go +++ b/relay/channel/task/hailuo/adaptor.go @@ -37,11 +37,141 @@ func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { a.apiKey = info.ApiKey } +// isV2Model 判断模型是否走 MiniMax H3 V2 接口(/v2/video_generation)。 +func isV2Model(model string) bool { + return model == "MiniMax-H3" +} + func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *taskdto.TaskError) { + // 校验发生在 ModelMappedHelper 之前,这里先解析渠道模型映射, + // 确保别名(如 h3 -> MiniMax-H3)也走 V2 校验。 + if isV2Model(resolveUpstreamModel(c, info.OriginModelName)) { + return validateV2Request(c, info) + } return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate) } +// resolveUpstreamModel 解析渠道模型映射后的最终上游模型名(链式、带循环保护)。 +// 语义与 relay/helper.ModelMappedHelper 保持一致;解析失败时回退到原始模型名。 +func resolveUpstreamModel(c *gin.Context, originModel string) string { + mappingStr := common.GetContextKeyString(c, constant.ContextKeyChannelModelMapping) + if mappingStr == "" || mappingStr == "{}" || originModel == "" { + return originModel + } + var modelMap map[string]string + if err := common.UnmarshalJsonStr(mappingStr, &modelMap); err != nil { + return originModel + } + current := originModel + visited := map[string]bool{current: true} + for { + mapped, exists := modelMap[current] + if !exists || mapped == "" || visited[mapped] { + break + } + visited[mapped] = true + current = mapped + } + return current +} + +func validateV2Request(c *gin.Context, info *relaycommon.RelayInfo) *taskdto.TaskError { + if taskErr := relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate); taskErr != nil { + return taskErr + } + + req, err := relaycommon.GetTaskRequest(c) + if err != nil { + return service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest) + } + + // duration 必须落在 [4, 15],同时作为计费倍率(seconds)必须先于估价钳制。 + if req.Duration > 0 && (req.Duration < V2MinDurationSeconds || req.Duration > V2MaxDurationSeconds) { + return service.TaskErrorWrapperLocal( + fmt.Errorf("duration must be between %d and %d for MiniMax-H3", V2MinDurationSeconds, V2MaxDurationSeconds), + "invalid_duration", http.StatusBadRequest) + } + if req.Duration == 0 { + req.Duration = V2DefaultDuration + } + + // resolution 仅支持 768P / 2K。 + if req.Size != "" && normalizeV2Resolution(req.Size) == "" { + return service.TaskErrorWrapperLocal(fmt.Errorf("resolution must be 768P or 2K for MiniMax-H3"), "invalid_resolution", http.StatusBadRequest) + } + + // ratio 必须是 V2 允许的值。 + if ratio, ok := req.Metadata["ratio"].(string); ok && ratio != "" && !isValidV2Ratio(ratio) { + return service.TaskErrorWrapperLocal(fmt.Errorf("invalid ratio %q for MiniMax-H3", ratio), "invalid_ratio", http.StatusBadRequest) + } + + // 图生视频(首帧/首尾帧)最多 2 张图片。 + if len(req.Images) > V2MaxFrameImages { + return service.TaskErrorWrapperLocal(fmt.Errorf("at most %d images are supported for image-to-video", V2MaxFrameImages), "invalid_image_count", http.StatusBadRequest) + } + + // metadata.content 透传路径也要做输入数量上限校验(图片/视频/音频都是计费相关输入)。 + if raw, ok := req.Metadata["content"]; ok && raw != nil { + if taskErr := validateV2ContentCounts(raw); taskErr != nil { + return taskErr + } + } + if n := len(metadataURLList(req.Metadata, "reference_video")); n > V2MaxReferenceVideos { + return service.TaskErrorWrapperLocal(fmt.Errorf("at most %d reference videos are supported", V2MaxReferenceVideos), "invalid_video_count", http.StatusBadRequest) + } + if n := len(metadataURLList(req.Metadata, "reference_audio")); n > V2MaxReferenceAudios { + return service.TaskErrorWrapperLocal(fmt.Errorf("at most %d reference audios are supported", V2MaxReferenceAudios), "invalid_audio_count", http.StatusBadRequest) + } + + c.Set("task_request", req) + return nil +} + +func isValidV2Ratio(ratio string) bool { + for _, r := range V2AllowedRatios { + if ratio == r { + return true + } + } + return false +} + +func validateV2ContentCounts(raw any) *taskdto.TaskError { + items, ok := raw.([]any) + if !ok { + return service.TaskErrorWrapperLocal(fmt.Errorf("metadata.content must be an array"), "invalid_content", http.StatusBadRequest) + } + var images, videos, audios int + for _, item := range items { + m, ok := item.(map[string]any) + if !ok { + continue + } + switch m["type"] { + case "image_url": + images++ + case "video_url": + videos++ + case "audio_url": + audios++ + } + } + if images > V2MaxReferenceImages { + return service.TaskErrorWrapperLocal(fmt.Errorf("at most %d reference images are supported", V2MaxReferenceImages), "invalid_image_count", http.StatusBadRequest) + } + if videos > V2MaxReferenceVideos { + return service.TaskErrorWrapperLocal(fmt.Errorf("at most %d reference videos are supported", V2MaxReferenceVideos), "invalid_video_count", http.StatusBadRequest) + } + if audios > V2MaxReferenceAudios { + return service.TaskErrorWrapperLocal(fmt.Errorf("at most %d reference audios are supported", V2MaxReferenceAudios), "invalid_audio_count", http.StatusBadRequest) + } + return nil +} + func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) { + if isV2Model(info.UpstreamModelName) { + return fmt.Sprintf("%s%s", a.baseURL, VideoGenerationV2Endpoint), nil + } return fmt.Sprintf("%s%s", a.baseURL, TextToVideoEndpoint), nil } @@ -62,7 +192,13 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayIn return nil, fmt.Errorf("invalid request type in context") } - body, err := a.convertToRequestPayload(&req, info) + var body any + var err error + if isV2Model(info.UpstreamModelName) { + body, err = buildV2RequestPayload(&req, info) + } else { + body, err = a.convertToRequestPayload(&req, info) + } if err != nil { return nil, errors.Wrap(err, "convert request payload failed") } @@ -75,6 +211,182 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayIn return bytes.NewReader(data), nil } +// buildV2RequestPayload 构造 MiniMax H3 V2 的创建任务请求体。 +func buildV2RequestPayload(req *relaycommon.TaskSubmitReq, info *relaycommon.RelayInfo) (*VideoGenerationV2Request, error) { + content, err := buildV2Content(req) + if err != nil { + return nil, err + } + if len(content) == 0 { + return nil, fmt.Errorf("content is empty") + } + + videoRequest := &VideoGenerationV2Request{ + Model: info.UpstreamModelName, + Content: content, + Resolution: resolveV2Resolution(req.Size, req.Metadata), + Duration: req.Duration, + Ratio: resolveV2Ratio(req), + } + if callbackURL, ok := req.Metadata["callback_url"].(string); ok { + videoRequest.CallbackURL = callbackURL + } + if watermark, ok := req.Metadata["aigc_watermark"].(bool); ok { + videoRequest.AigcWatermark = &watermark + } + return videoRequest, nil +} + +// buildV2Content 构造 V2 content 数组: +// - metadata.content 透传优先(完整多模态引用场景),缺失非空 text 项时自动补充 prompt; +// - 否则由 prompt + images(首帧/首尾帧)+ reference_video/reference_audio 组装。 +func buildV2Content(req *relaycommon.TaskSubmitReq) ([]V2ContentItem, error) { + if raw, ok := req.Metadata["content"]; ok && raw != nil { + items, err := parseV2ContentItems(raw) + if err != nil { + return nil, err + } + hasText := false + for _, item := range items { + if item.Type == "text" && strings.TrimSpace(item.Text) != "" { + hasText = true + break + } + } + if !hasText { + items = append([]V2ContentItem{{Type: "text", Text: req.Prompt}}, items...) + } + return items, nil + } + + content := make([]V2ContentItem, 0, 4) + content = append(content, V2ContentItem{Type: "text", Text: req.Prompt}) + + switch len(req.Images) { + case 1: + content = append(content, v2MediaItem("image_url", req.Images[0], "first_frame")) + case 2: + content = append(content, + v2MediaItem("image_url", req.Images[0], "first_frame"), + v2MediaItem("image_url", req.Images[1], "last_frame"), + ) + } + + for _, u := range metadataURLList(req.Metadata, "reference_video") { + content = append(content, v2MediaItem("video_url", u, "reference_video")) + } + for _, u := range metadataURLList(req.Metadata, "reference_audio") { + content = append(content, v2MediaItem("audio_url", u, "reference_audio")) + } + return content, nil +} + +func parseV2ContentItems(raw any) ([]V2ContentItem, error) { + data, err := common.Marshal(raw) + if err != nil { + return nil, errors.Wrap(err, "marshal metadata content failed") + } + var items []V2ContentItem + if err := common.Unmarshal(data, &items); err != nil { + return nil, errors.Wrap(err, "unmarshal metadata content failed") + } + return items, nil +} + +func v2MediaItem(itemType, url, role string) V2ContentItem { + item := V2ContentItem{Type: itemType, Role: role} + switch itemType { + case "image_url": + item.ImageURL = &V2MediaURL{URL: url} + case "video_url": + item.VideoURL = &V2MediaURL{URL: url} + case "audio_url": + item.AudioURL = &V2MediaURL{URL: url} + } + return item +} + +// metadataURLList 读取 metadata 中的 URL 字段,支持单字符串或字符串数组。 +func metadataURLList(metadata map[string]any, key string) []string { + raw, ok := metadata[key] + if !ok || raw == nil { + return nil + } + switch v := raw.(type) { + case string: + if strings.TrimSpace(v) != "" { + return []string{v} + } + case []any: + var urls []string + for _, u := range v { + if s, ok := u.(string); ok && strings.TrimSpace(s) != "" { + urls = append(urls, s) + } + } + return urls + case []string: + return v + } + return nil +} + +// normalizeV2Resolution 将 size 归一化为 V2 分辨率;不支持的 size 返回空串。 +func normalizeV2Resolution(size string) string { + switch { + case strings.Contains(size, "2K"): + return V2Resolution2K + case strings.Contains(size, "768"): + return Resolution768P + default: + return "" + } +} + +func resolveV2Resolution(size string, metadata map[string]any) string { + if r, ok := metadata["resolution"].(string); ok && r != "" { + if v := normalizeV2Resolution(r); v != "" { + return v + } + } + if v := normalizeV2Resolution(size); v != "" { + return v + } + return Resolution768P +} + +func resolveV2Ratio(req *relaycommon.TaskSubmitReq) string { + if ratio, ok := req.Metadata["ratio"].(string); ok && ratio != "" { + return ratio + } + if hasV2VisualInput(req) { + return "adaptive" + } + return V2DefaultRatio +} + +func hasV2VisualInput(req *relaycommon.TaskSubmitReq) bool { + if len(req.Images) > 0 { + return true + } + if raw, ok := req.Metadata["content"]; ok && raw != nil { + if items, err := parseV2ContentItems(raw); err == nil { + for _, item := range items { + if item.Type == "image_url" || item.Type == "video_url" { + return true + } + } + } + } + if len(metadataURLList(req.Metadata, "reference_video")) > 0 { + return true + } + if len(metadataURLList(req.Metadata, "reference_audio")) > 0 { + return true + } + return false +} + func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) { return channel.DoTaskApiRequest(a, c, info, requestBody) } @@ -87,6 +399,20 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela } _ = resp.Body.Close() + if isV2Model(info.UpstreamModelName) { + var v2Resp VideoGenerationV2Response + if err := common.Unmarshal(responseBody, &v2Resp); err != nil { + taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError) + return + } + if strings.TrimSpace(v2Resp.TaskID) == "" { + taskErr = service.TaskErrorWrapper(fmt.Errorf("task_id is empty"), "invalid_response", http.StatusInternalServerError) + return + } + c.JSON(http.StatusOK, newOpenAIVideoResponse(info)) + return v2Resp.TaskID, responseBody, nil + } + var hResp VideoResponse if err := common.Unmarshal(responseBody, &hResp); err != nil { taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError) @@ -102,14 +428,17 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela return } + c.JSON(http.StatusOK, newOpenAIVideoResponse(info)) + return hResp.TaskID, responseBody, nil +} + +func newOpenAIVideoResponse(info *relaycommon.RelayInfo) *dto.OpenAIVideo { ov := dto.NewOpenAIVideo() ov.ID = info.PublicTaskID ov.TaskID = info.PublicTaskID ov.CreatedAt = time.Now().Unix() ov.Model = info.OriginModelName - - c.JSON(http.StatusOK, ov) - return hResp.TaskID, responseBody, nil + return ov } func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy string) (*http.Response, error) { @@ -118,7 +447,13 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy return nil, fmt.Errorf("invalid task_id") } - uri := fmt.Sprintf("%s%s?task_id=%s", baseUrl, QueryTaskEndpoint, taskID) + modelName, _ := body["model"].(string) + uri := "" + if isV2Model(modelName) { + uri = fmt.Sprintf("%s%s/%s", baseUrl, QueryTaskV2Endpoint, taskID) + } else { + uri = fmt.Sprintf("%s%s?task_id=%s", baseUrl, QueryTaskEndpoint, taskID) + } req, err := http.NewRequest(http.MethodGet, uri, nil) if err != nil { @@ -182,7 +517,70 @@ func (a *TaskAdaptor) parseResolutionFromSize(size string, modelConfig ModelConf } } +// EstimateBilling 仅对 MiniMax H3 V2 生效:按输出秒数与分辨率倍率计费。 +// 官方定价 2K 0.80 元/秒、768P 0.50 元/秒,基础模型单价按 768P 每秒配置即可。 +func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInfo) map[string]float64 { + if !isV2Model(info.UpstreamModelName) { + return nil + } + req, err := relaycommon.GetTaskRequest(c) + if err != nil { + return nil + } + seconds := req.Duration + if seconds < V2MinDurationSeconds { + seconds = V2MinDurationSeconds + } + if seconds > V2MaxDurationSeconds { + seconds = V2MaxDurationSeconds + } + + resRatio := 1.0 + if resolveV2Resolution(req.Size, req.Metadata) == V2Resolution2K { + resRatio = V2ResolutionRatio2K + } + + return map[string]float64{ + "seconds": float64(seconds), + "resolution": resRatio, + } +} + +// ParseTaskResult 同时兼容 V2({"task": {...}})与 V1({"task_id", "base_resp"})两种查询响应。 func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { + var v2Resp V2QueryTaskResponse + if err := common.Unmarshal(respBody, &v2Resp); err == nil && v2Resp.Task != nil { + return parseV2TaskResult(v2Resp.Task), nil + } + return a.parseV1TaskResult(respBody) +} + +func parseV2TaskResult(task *V2Task) *relaycommon.TaskInfo { + taskResult := relaycommon.TaskInfo{Code: 0} + switch task.Status { + case V2StatusQueued: + taskResult.Status = model.TaskStatusQueued + case V2StatusRunning: + taskResult.Status = model.TaskStatusInProgress + case V2StatusSucceeded: + taskResult.Status = model.TaskStatusSuccess + if task.Content != nil { + taskResult.Url = task.Content.URL + } + case V2StatusFailed, V2StatusCancelled: + taskResult.Status = model.TaskStatusFailure + if task.Error != nil && strings.TrimSpace(task.Error.Message) != "" { + taskResult.Reason = task.Error.Message + } else { + taskResult.Reason = "task " + task.Status + } + default: + taskResult.Status = model.TaskStatusInProgress + } + return &taskResult +} + +func (a *TaskAdaptor) parseV1TaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { resTask := QueryTaskResponse{} if err := common.Unmarshal(respBody, &resTask); err != nil { return nil, errors.Wrap(err, "unmarshal task result failed") @@ -225,6 +623,34 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e } func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, error) { + var v2Resp V2QueryTaskResponse + if err := common.Unmarshal(originTask.Data, &v2Resp); err == nil && v2Resp.Task != nil { + return convertToV2OpenAIVideo(originTask, v2Resp.Task) + } + return convertV1ToOpenAIVideo(originTask) +} + +func convertToV2OpenAIVideo(originTask *model.Task, task *V2Task) ([]byte, error) { + openAIVideo := originTask.ToOpenAIVideo() + if task.Status == V2StatusFailed || task.Status == V2StatusCancelled { + message, code := "", "" + if task.Error != nil { + message = task.Error.Message + code = task.Error.Code + } + if message == "" { + message = "task " + task.Status + } + openAIVideo.Error = &dto.OpenAIVideoError{Message: message, Code: code} + } + jsonData, err := common.Marshal(openAIVideo) + if err != nil { + return nil, errors.Wrap(err, "marshal openai video failed") + } + return jsonData, nil +} + +func convertV1ToOpenAIVideo(originTask *model.Task) ([]byte, error) { var hailuoResp QueryTaskResponse if err := common.Unmarshal(originTask.Data, &hailuoResp); err != nil { return nil, errors.Wrap(err, "unmarshal hailuo task data failed") diff --git a/relay/channel/task/hailuo/adaptor_test.go b/relay/channel/task/hailuo/adaptor_test.go new file mode 100644 index 000000000000..bbe11ef6c8c9 --- /dev/null +++ b/relay/channel/task/hailuo/adaptor_test.go @@ -0,0 +1,336 @@ +package hailuo + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relaykit/dto" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newV2TestContext(t *testing.T, body string) *gin.Context { + t.Helper() + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", bytes.NewReader([]byte(body))) + c.Request.Header.Set("Content-Type", "application/json") + t.Cleanup(func() { common.CleanupBodyStorage(c) }) + return c +} + +func TestValidateV2Request(t *testing.T) { + tests := []struct { + name string + body string + wantErr bool + wantDur int + }{ + {"text only defaults duration", `{"model":"MiniMax-H3","prompt":"a boy playing basketball"}`, false, V2DefaultDuration}, + {"valid 2K text request", `{"model":"MiniMax-H3","prompt":"a boy playing basketball","duration":5,"size":"2K"}`, false, 5}, + {"duration too short", `{"model":"MiniMax-H3","prompt":"p","duration":3}`, true, 0}, + {"duration too long", `{"model":"MiniMax-H3","prompt":"p","duration":16}`, true, 0}, + {"unsupported resolution", `{"model":"MiniMax-H3","prompt":"p","duration":5,"size":"1080P"}`, true, 0}, + {"invalid ratio", `{"model":"MiniMax-H3","prompt":"p","duration":5,"metadata":{"ratio":"16:10"}}`, true, 0}, + {"too many frame images", `{"model":"MiniMax-H3","prompt":"p","duration":5,"images":["a","b","c"]}`, true, 0}, + {"content must be array", `{"model":"MiniMax-H3","prompt":"p","duration":5,"metadata":{"content":"nope"}}`, true, 0}, + {"too many reference videos", + `{"model":"MiniMax-H3","prompt":"p","duration":5,"metadata":{"content":[ + {"type":"video_url","video_url":{"url":"u1"}}, + {"type":"video_url","video_url":{"url":"u2"}}, + {"type":"video_url","video_url":{"url":"u3"}}, + {"type":"video_url","video_url":{"url":"u4"}}]}}`, true, 0}, + {"valid metadata content", + `{"model":"MiniMax-H3","prompt":"p","duration":5,"metadata":{"content":[ + {"type":"image_url","image_url":{"url":"u1"},"role":"reference_image"}]}}`, false, 5}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := newV2TestContext(t, tt.body) + info := &relaycommon.RelayInfo{TaskRelayInfo: &relaycommon.TaskRelayInfo{}, OriginModelName: "MiniMax-H3"} + taskErr := (&TaskAdaptor{}).ValidateRequestAndSetAction(c, info) + if tt.wantErr { + require.NotNil(t, taskErr) + return + } + require.Nil(t, taskErr) + req, err := relaycommon.GetTaskRequest(c) + require.NoError(t, err) + assert.Equal(t, tt.wantDur, req.Duration) + }) + } +} + +func TestValidateV2RequestKeepsV1Path(t *testing.T) { + // V1 模型不应触发 V2 的 4-15 秒限制 + c := newV2TestContext(t, `{"model":"MiniMax-Hailuo-2.3","prompt":"p","duration":20}`) + info := &relaycommon.RelayInfo{TaskRelayInfo: &relaycommon.TaskRelayInfo{}, OriginModelName: "MiniMax-Hailuo-2.3"} + taskErr := (&TaskAdaptor{}).ValidateRequestAndSetAction(c, info) + require.Nil(t, taskErr) +} + +func TestBuildV2Content(t *testing.T) { + t.Run("text only", func(t *testing.T) { + req := &relaycommon.TaskSubmitReq{Prompt: "a boy playing basketball"} + content, err := buildV2Content(req) + require.NoError(t, err) + require.Len(t, content, 1) + assert.Equal(t, "text", content[0].Type) + assert.Equal(t, "a boy playing basketball", content[0].Text) + }) + t.Run("first and last frame", func(t *testing.T) { + req := &relaycommon.TaskSubmitReq{Prompt: "p", Images: []string{"first.png", "last.png"}} + content, err := buildV2Content(req) + require.NoError(t, err) + require.Len(t, content, 3) + assert.Equal(t, "first_frame", content[1].Role) + assert.Equal(t, "first.png", content[1].ImageURL.URL) + assert.Equal(t, "last_frame", content[2].Role) + assert.Equal(t, "last.png", content[2].ImageURL.URL) + }) + t.Run("reference video and audio", func(t *testing.T) { + req := &relaycommon.TaskSubmitReq{ + Prompt: "p", + Metadata: map[string]any{ + "reference_video": "ref.mp4", + "reference_audio": []any{"a.mp3", "b.mp3"}, + }, + } + content, err := buildV2Content(req) + require.NoError(t, err) + require.Len(t, content, 4) + assert.Equal(t, "video_url", content[1].Type) + assert.Equal(t, "reference_video", content[1].Role) + assert.Equal(t, "ref.mp4", content[1].VideoURL.URL) + assert.Equal(t, "audio_url", content[2].Type) + assert.Equal(t, "reference_audio", content[2].Role) + assert.Equal(t, "a.mp3", content[2].AudioURL.URL) + assert.Equal(t, "b.mp3", content[3].AudioURL.URL) + }) + t.Run("content passthrough prepends missing text", func(t *testing.T) { + req := &relaycommon.TaskSubmitReq{ + Prompt: "p", + Metadata: map[string]any{ + "content": []any{ + map[string]any{"type": "image_url", "image_url": map[string]any{"url": "img.png"}, "role": "first_frame"}, + }, + }, + } + content, err := buildV2Content(req) + require.NoError(t, err) + require.Len(t, content, 2) + assert.Equal(t, "text", content[0].Type) + assert.Equal(t, "p", content[0].Text) + assert.Equal(t, "image_url", content[1].Type) + assert.Equal(t, "img.png", content[1].ImageURL.URL) + }) +} + +func TestBuildV2RequestPayload(t *testing.T) { + req := &relaycommon.TaskSubmitReq{ + Prompt: "p", + Duration: 5, + Size: "2K", + Metadata: map[string]any{ + "ratio": "16:9", + "callback_url": "https://example.com/cb", + "aigc_watermark": true, + }, + } + payload, err := buildV2RequestPayload(req, &relaycommon.RelayInfo{TaskRelayInfo: &relaycommon.TaskRelayInfo{}, ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "MiniMax-H3"}}) + require.NoError(t, err) + assert.Equal(t, "MiniMax-H3", payload.Model) + assert.Equal(t, "2K", payload.Resolution) + assert.Equal(t, 5, payload.Duration) + assert.Equal(t, "16:9", payload.Ratio) + assert.Equal(t, "https://example.com/cb", payload.CallbackURL) + require.NotNil(t, payload.AigcWatermark) + assert.True(t, *payload.AigcWatermark) +} + +func TestBuildV2RequestPayloadDefaultsAdaptiveForImages(t *testing.T) { + req := &relaycommon.TaskSubmitReq{ + Prompt: "p", + Duration: 5, + Images: []string{"img.png"}, + } + payload, err := buildV2RequestPayload(req, &relaycommon.RelayInfo{TaskRelayInfo: &relaycommon.TaskRelayInfo{}, ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "MiniMax-H3"}}) + require.NoError(t, err) + assert.Equal(t, "adaptive", payload.Ratio) + assert.Equal(t, "768P", payload.Resolution) +} + +func TestEstimateBillingV2(t *testing.T) { + tests := []struct { + name string + model string + req relaycommon.TaskSubmitReq + want map[string]float64 + }{ + {"2K text", "MiniMax-H3", relaycommon.TaskSubmitReq{Duration: 5, Size: "2K"}, map[string]float64{"seconds": 5, "resolution": 1.6}}, + {"768P default", "MiniMax-H3", relaycommon.TaskSubmitReq{Duration: 10}, map[string]float64{"seconds": 10, "resolution": 1}}, + {"oversized duration clamped", "MiniMax-H3", relaycommon.TaskSubmitReq{Duration: 9999}, map[string]float64{"seconds": 15, "resolution": 1}}, + {"V1 model returns nil", "MiniMax-Hailuo-2.3", relaycommon.TaskSubmitReq{Duration: 5}, nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Set("task_request", tt.req) + info := &relaycommon.RelayInfo{TaskRelayInfo: &relaycommon.TaskRelayInfo{}, ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: tt.model}} + got := (&TaskAdaptor{}).EstimateBilling(c, info) + if tt.want == nil { + assert.Nil(t, got) + return + } + require.NotNil(t, got) + assert.Equal(t, tt.want["seconds"], got["seconds"]) + assert.Equal(t, tt.want["resolution"], got["resolution"]) + }) + } +} + +func TestParseTaskResultV2(t *testing.T) { + tests := []struct { + name string + body string + wantStatus model.TaskStatus + wantURL string + wantReason string + }{ + {"queued", `{"task":{"id":"1","status":"queued"}}`, model.TaskStatusQueued, "", ""}, + {"running", `{"task":{"id":"1","status":"running"}}`, model.TaskStatusInProgress, "", ""}, + {"succeeded with url", `{"task":{"id":"1","status":"succeeded","content":{"url":"https://cdn/x.mp4"}}}`, model.TaskStatusSuccess, "https://cdn/x.mp4", ""}, + {"failed with error", `{"task":{"id":"1","status":"failed","error":{"code":"1026","message":"sensitive content"}}}`, model.TaskStatusFailure, "", "sensitive content"}, + {"cancelled", `{"task":{"id":"1","status":"cancelled"}}`, model.TaskStatusFailure, "", "task cancelled"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ti, err := (&TaskAdaptor{}).ParseTaskResult([]byte(tt.body)) + require.NoError(t, err) + assert.Equal(t, string(tt.wantStatus), string(ti.Status)) + assert.Equal(t, tt.wantURL, ti.Url) + assert.Equal(t, tt.wantReason, ti.Reason) + }) + } +} + +func TestParseTaskResultV1Fallback(t *testing.T) { + body := `{"task_id":"123","status":"Success","file_id":"f1","base_resp":{"status_code":0,"status_msg":""}}` + ti, err := (&TaskAdaptor{}).ParseTaskResult([]byte(body)) + require.NoError(t, err) + assert.Equal(t, string(model.TaskStatusSuccess), string(ti.Status)) + // apiKey 为空时 buildVideoURL 直接返回空串 + assert.Equal(t, "", ti.Url) +} + +func TestConvertToV2OpenAIVideoFailed(t *testing.T) { + task := &model.Task{ + TaskID: "task_abc", + Status: model.TaskStatusFailure, + Progress: "100%", + Properties: model.Properties{OriginModelName: "MiniMax-H3"}, + Data: []byte(`{"task":{"id":"1","status":"failed","error":{"code":"1026","message":"sensitive"}}}`), + } + data, err := (&TaskAdaptor{}).ConvertToOpenAIVideo(task) + require.NoError(t, err) + var ov dto.OpenAIVideo + require.NoError(t, common.Unmarshal(data, &ov)) + assert.Equal(t, dto.VideoStatusFailed, ov.Status) + require.NotNil(t, ov.Error) + assert.Equal(t, "1026", ov.Error.Code) + assert.Equal(t, "sensitive", ov.Error.Message) +} + +func TestConvertToV2OpenAIVideoSubmitResponse(t *testing.T) { + // 提交响应(无 task 对象)走通用转换,不报错、不带错误信息 + task := &model.Task{ + TaskID: "task_abc", + Status: model.TaskStatusQueued, + Properties: model.Properties{OriginModelName: "MiniMax-H3"}, + Data: []byte(`{"task_id":"123"}`), + } + data, err := (&TaskAdaptor{}).ConvertToOpenAIVideo(task) + require.NoError(t, err) + var ov dto.OpenAIVideo + require.NoError(t, common.Unmarshal(data, &ov)) + assert.Equal(t, dto.VideoStatusQueued, ov.Status) + assert.Nil(t, ov.Error) +} + +func TestValidateV2RequestWithMappedModel(t *testing.T) { + // 渠道模型映射别名 h3 -> MiniMax-H3 时也必须走 V2 校验(duration 3 应被拒绝) + c := newV2TestContext(t, `{"model":"h3","prompt":"p","duration":3}`) + c.Set("model_mapping", `{"h3":"MiniMax-H3"}`) + info := &relaycommon.RelayInfo{TaskRelayInfo: &relaycommon.TaskRelayInfo{}, OriginModelName: "h3"} + taskErr := (&TaskAdaptor{}).ValidateRequestAndSetAction(c, info) + require.NotNil(t, taskErr) + assert.Equal(t, "invalid_duration", taskErr.Code) +} + +func TestValidateV2RequestMappedValid(t *testing.T) { + c := newV2TestContext(t, `{"model":"h3","prompt":"p","duration":5,"size":"2K"}`) + c.Set("model_mapping", `{"h3":"MiniMax-H3"}`) + info := &relaycommon.RelayInfo{TaskRelayInfo: &relaycommon.TaskRelayInfo{}, OriginModelName: "h3"} + taskErr := (&TaskAdaptor{}).ValidateRequestAndSetAction(c, info) + require.Nil(t, taskErr) + req, err := relaycommon.GetTaskRequest(c) + require.NoError(t, err) + assert.Equal(t, 5, req.Duration) +} + +func TestResolveUpstreamModel(t *testing.T) { + tests := []struct { + name string + mapping string + origin string + want string + }{ + {"no mapping", "", "h3", "h3"}, + {"direct alias", `{"h3":"MiniMax-H3"}`, "h3", "MiniMax-H3"}, + {"chain mapping", `{"a":"b","b":"MiniMax-H3"}`, "a", "MiniMax-H3"}, + {"self mapping keeps origin", `{"h3":"h3"}`, "h3", "h3"}, + {"cycle stops at first hop", `{"a":"b","b":"a"}`, "a", "b"}, + {"unmapped origin", `{"h3":"MiniMax-H3"}`, "other", "other"}, + {"malformed mapping falls back", "not-json", "h3", "h3"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + if tt.mapping != "" { + c.Set("model_mapping", tt.mapping) + } + assert.Equal(t, tt.want, resolveUpstreamModel(c, tt.origin)) + }) + } +} + +func TestFetchTaskEndpointByModel(t *testing.T) { + tests := []struct { + name string + model string + want string + }{ + {"v2 upstream model", "MiniMax-H3", "/v2/query/video_generation/123"}, + {"v1 legacy model", "MiniMax-Hailuo-2.3", "/v1/query/video_generation?task_id=123"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.RequestURI() + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + resp, err := (&TaskAdaptor{}).FetchTask(srv.URL, "key", map[string]any{"task_id": "123", "model": tt.model}, "") + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, tt.want, gotPath) + }) + } +} diff --git a/relay/channel/task/hailuo/constants.go b/relay/channel/task/hailuo/constants.go index 5e54086374f9..1d0a6caf0500 100644 --- a/relay/channel/task/hailuo/constants.go +++ b/relay/channel/task/hailuo/constants.go @@ -5,6 +5,7 @@ const ( ) var ModelList = []string{ + "MiniMax-H3", "MiniMax-Hailuo-2.3", "MiniMax-Hailuo-2.3-Fast", "MiniMax-Hailuo-02", @@ -21,6 +22,44 @@ const ( QueryTaskEndpoint = "/v1/query/video_generation" ) +const ( + // VideoGenerationV2Endpoint 是 MiniMax H3 V2 视频生成创建接口。 + VideoGenerationV2Endpoint = "/v2/video_generation" + // QueryTaskV2Endpoint 是 MiniMax H3 V2 视频生成查询接口(task_id 走 path 参数)。 + QueryTaskV2Endpoint = "/v2/query/video_generation" +) + +const ( + V2Resolution2K = "2K" + + V2MinDurationSeconds = 4 + V2MaxDurationSeconds = 15 + V2DefaultDuration = 5 + + // V2DefaultRatio 仅文本输入的文生视频场景下 ratio 必填且不能为 adaptive。 + V2DefaultRatio = "16:9" + + // V2ResolutionRatio2K 官方定价:2K 0.80 元/秒,768P 0.50 元/秒。 + V2ResolutionRatio2K = 1.6 + + // V2MaxFrameImages 图生视频(首帧/首尾帧)最多 2 张图片。 + V2MaxFrameImages = 2 + // V2MaxReferenceImages / V2MaxReferenceVideos / V2MaxReferenceAudios 是多模态参考场景的输入数量上限。 + V2MaxReferenceImages = 9 + V2MaxReferenceVideos = 3 + V2MaxReferenceAudios = 3 +) + +const ( + V2StatusQueued = "queued" + V2StatusRunning = "running" + V2StatusSucceeded = "succeeded" + V2StatusFailed = "failed" + V2StatusCancelled = "cancelled" +) + +var V2AllowedRatios = []string{"adaptive", "21:9", "16:9", "4:3", "1:1", "3:4", "9:16"} + const ( StatusSuccess = 0 StatusRateLimit = 1002 diff --git a/relay/channel/task/hailuo/e2e_test.go b/relay/channel/task/hailuo/e2e_test.go new file mode 100644 index 000000000000..356784fb7d8c --- /dev/null +++ b/relay/channel/task/hailuo/e2e_test.go @@ -0,0 +1,197 @@ +package hailuo + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relaykit/dto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// v2E2ERecorder 记录 httptest 上游收到的创建/查询请求,供端到端断言使用。 +type v2E2ERecorder struct { + mu sync.Mutex + taskID string + createPath string + createBody []byte + createAuth string + createContentType string + queryPath string + queryAuth string +} + +// newV2E2EServer 模拟 MiniMax H3 V2 上游:创建任务 + 查询任务。 +func newV2E2EServer(t *testing.T, taskID string) (*httptest.Server, *v2E2ERecorder) { + t.Helper() + rec := &v2E2ERecorder{taskID: taskID} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == VideoGenerationV2Endpoint: + body, _ := io.ReadAll(r.Body) + rec.mu.Lock() + rec.createPath = r.URL.Path + rec.createBody = body + rec.createAuth = r.Header.Get("Authorization") + rec.createContentType = r.Header.Get("Content-Type") + rec.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"task_id":"`+taskID+`"}`) + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, QueryTaskV2Endpoint+"/"): + rec.mu.Lock() + rec.queryPath = r.URL.Path + rec.queryAuth = r.Header.Get("Authorization") + rec.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"task":{"id":"`+taskID+`","status":"succeeded","content":{"url":"https://cdn.example.com/h3.mp4"},"usage":{"total_seconds":5}}}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + return srv, rec +} + +func newV2E2EInfo(srv *httptest.Server, originModel string) *relaycommon.RelayInfo { + return &relaycommon.RelayInfo{ + TaskRelayInfo: &relaycommon.TaskRelayInfo{PublicTaskID: "task_pub_1"}, + OriginModelName: originModel, + ChannelMeta: &relaycommon.ChannelMeta{ + ApiKey: "sk-test", + ChannelBaseUrl: srv.URL, + UpstreamModelName: "MiniMax-H3", + }, + } +} + +// TestEndToEndV2SubmitPollConvert 覆盖完整链路:映射别名校验 -> 真实 HTTP 提交 -> +// 响应解析 -> 轮询 -> 任务结果解析 -> OpenAI 视频格式转换。 +func TestEndToEndV2SubmitPollConvert(t *testing.T) { + const upstreamTaskID = "upstream_001" + srv, rec := newV2E2EServer(t, upstreamTaskID) + + c := newV2TestContext(t, `{"model":"h3","prompt":"a boy playing basketball"}`) + c.Set("model_mapping", `{"h3":"MiniMax-H3"}`) + info := newV2E2EInfo(srv, "h3") + + adaptor := &TaskAdaptor{} + adaptor.Init(info) + + // 1. 校验:渠道模型映射别名 h3 -> MiniMax-H3 必须走 V2 校验。 + taskErr := adaptor.ValidateRequestAndSetAction(c, info) + require.Nil(t, taskErr) + + // 2. 构建请求体并真实提交。 + body, err := adaptor.BuildRequestBody(c, info) + require.NoError(t, err) + resp, err := adaptor.DoRequest(c, info, body) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + // 3. 断言提交请求:路径、认证头、请求体形状。 + rec.mu.Lock() + require.Equal(t, VideoGenerationV2Endpoint, rec.createPath) + require.Equal(t, "Bearer sk-test", rec.createAuth) + require.Equal(t, "application/json", rec.createContentType) + createBody := append([]byte(nil), rec.createBody...) + rec.mu.Unlock() + + var payload VideoGenerationV2Request + require.NoError(t, common.Unmarshal(createBody, &payload)) + assert.Equal(t, "MiniMax-H3", payload.Model) + assert.Equal(t, Resolution768P, payload.Resolution) + assert.Equal(t, V2DefaultDuration, payload.Duration) + assert.Equal(t, V2DefaultRatio, payload.Ratio) + require.Len(t, payload.Content, 1) + assert.Equal(t, "text", payload.Content[0].Type) + assert.Equal(t, "a boy playing basketball", payload.Content[0].Text) + + // 4. DoResponse 解析出上游 task_id 并写 OpenAI 格式响应。 + taskID, taskData, taskErr := adaptor.DoResponse(c, resp, info) + require.Nil(t, taskErr) + assert.Equal(t, upstreamTaskID, taskID) + require.NotEmpty(t, taskData) + + // 5. 轮询:按映射后的模型名打到 V2 查询端点。 + pollResp, err := adaptor.FetchTask(srv.URL, "sk-test", map[string]any{ + "task_id": taskID, + "model": "MiniMax-H3", + }, "") + require.NoError(t, err) + defer pollResp.Body.Close() + pollBody, err := io.ReadAll(pollResp.Body) + require.NoError(t, err) + + rec.mu.Lock() + require.Equal(t, QueryTaskV2Endpoint+"/"+upstreamTaskID, rec.queryPath) + require.Equal(t, "Bearer sk-test", rec.queryAuth) + rec.mu.Unlock() + + // 6. 解析查询结果为任务终态 + URL。 + ti, err := adaptor.ParseTaskResult(pollBody) + require.NoError(t, err) + assert.Equal(t, string(model.TaskStatusSuccess), string(ti.Status)) + assert.Equal(t, "https://cdn.example.com/h3.mp4", ti.Url) + + // 7. 转换为最终 OpenAI 视频格式。 + task := &model.Task{ + TaskID: "task_pub_1", + Status: model.TaskStatusSuccess, + Properties: model.Properties{OriginModelName: "h3"}, + Data: pollBody, + } + ovData, err := adaptor.ConvertToOpenAIVideo(task) + require.NoError(t, err) + var ov dto.OpenAIVideo + require.NoError(t, common.Unmarshal(ovData, &ov)) + assert.Equal(t, dto.VideoStatusCompleted, ov.Status) +} + +// TestEndToEndV2SubmitFirstLastFrame 覆盖图生视频(首尾帧 + 2K)提交链路: +// content 三项、ratio 自适应为 adaptive、resolution 归一化为 2K。 +func TestEndToEndV2SubmitFirstLastFrame(t *testing.T) { + srv, rec := newV2E2EServer(t, "upstream_002") + + c := newV2TestContext(t, `{"model":"MiniMax-H3","prompt":"smooth camera move","duration":5,"size":"2K","images":["first.png","last.png"]}`) + info := newV2E2EInfo(srv, "MiniMax-H3") + + adaptor := &TaskAdaptor{} + adaptor.Init(info) + + taskErr := adaptor.ValidateRequestAndSetAction(c, info) + require.Nil(t, taskErr) + + body, err := adaptor.BuildRequestBody(c, info) + require.NoError(t, err) + resp, err := adaptor.DoRequest(c, info, body) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + rec.mu.Lock() + require.Equal(t, VideoGenerationV2Endpoint, rec.createPath) + createBody := append([]byte(nil), rec.createBody...) + rec.mu.Unlock() + + var payload VideoGenerationV2Request + require.NoError(t, common.Unmarshal(createBody, &payload)) + assert.Equal(t, "2K", payload.Resolution) + assert.Equal(t, "adaptive", payload.Ratio) + require.Len(t, payload.Content, 3) + assert.Equal(t, "text", payload.Content[0].Type) + assert.Equal(t, "first_frame", payload.Content[1].Role) + assert.Equal(t, "first.png", payload.Content[1].ImageURL.URL) + assert.Equal(t, "last_frame", payload.Content[2].Role) + assert.Equal(t, "last.png", payload.Content[2].ImageURL.URL) + + _, _, taskErr = adaptor.DoResponse(c, resp, info) + require.Nil(t, taskErr) +} diff --git a/relay/channel/task/hailuo/models.go b/relay/channel/task/hailuo/models.go index 09a97766f15d..247a0824daa5 100644 --- a/relay/channel/task/hailuo/models.go +++ b/relay/channel/task/hailuo/models.go @@ -81,6 +81,14 @@ type FileObject struct { func GetModelConfig(model string) ModelConfig { configs := map[string]ModelConfig{ + "MiniMax-H3": { + Name: "MiniMax-H3", + DefaultResolution: Resolution768P, + SupportedDurations: []int{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}, + SupportedResolutions: []string{Resolution768P, V2Resolution2K}, + HasPromptOptimizer: false, + HasFastPretreatment: false, + }, "MiniMax-Hailuo-2.3": { Name: "MiniMax-Hailuo-2.3", DefaultResolution: Resolution768P, @@ -168,3 +176,69 @@ func GetModelConfig(model string) ModelConfig { HasFastPretreatment: false, } } + +// ---- MiniMax H3 V2 (Hailuo-03) DTOs ---- + +type V2MediaURL struct { + URL string `json:"url"` +} + +type V2ContentItem struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ImageURL *V2MediaURL `json:"image_url,omitempty"` + VideoURL *V2MediaURL `json:"video_url,omitempty"` + AudioURL *V2MediaURL `json:"audio_url,omitempty"` + Role string `json:"role,omitempty"` +} + +type VideoGenerationV2Request struct { + Model string `json:"model"` + Content []V2ContentItem `json:"content"` + Resolution string `json:"resolution"` + Duration int `json:"duration"` + Ratio string `json:"ratio,omitempty"` + CallbackURL string `json:"callback_url,omitempty"` + AigcWatermark *bool `json:"aigc_watermark,omitempty"` +} + +type VideoGenerationV2Response struct { + TaskID string `json:"task_id"` +} + +type V2TaskError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type V2TaskContent struct { + URL string `json:"url,omitempty"` + Prompt string `json:"prompt,omitempty"` +} + +type V2TaskUsage struct { + TotalSeconds int `json:"total_seconds,omitempty"` + InputSeconds int `json:"input_seconds,omitempty"` + OutputSeconds int `json:"output_seconds,omitempty"` + InputImageCount int `json:"input_image_count,omitempty"` +} + +type V2Task struct { + ID string `json:"id"` + Model string `json:"model"` + Status string `json:"status"` + Error *V2TaskError `json:"error,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + Content *V2TaskContent `json:"content,omitempty"` + Resolution string `json:"resolution,omitempty"` + Duration int `json:"duration,omitempty"` + Usage *V2TaskUsage `json:"usage,omitempty"` + Ratio string `json:"ratio,omitempty"` + TaskType string `json:"task_type,omitempty"` + Modality string `json:"modality,omitempty"` +} + +type V2QueryTaskResponse struct { + Task *V2Task `json:"task"` +} diff --git a/service/hailuo_v2_e2e_test.go b/service/hailuo_v2_e2e_test.go new file mode 100644 index 000000000000..080dc9002c8f --- /dev/null +++ b/service/hailuo_v2_e2e_test.go @@ -0,0 +1,100 @@ +package service_test + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relay/channel/task/hailuo" + "github.com/QuantumNous/new-api/service" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestUpdateVideoTasksV2EndToEnd 覆盖审核评论的核心场景:渠道模型映射别名 +// (h3 -> MiniMax-H3)提交的任务,轮询必须携带正确的模型名并打到 V2 查询端点, +// 任务完成后状态与结果 URL 正确落库。 +func TestUpdateVideoTasksV2EndToEnd(t *testing.T) { + tests := []struct { + name string + properties model.Properties + }{ + {"mapped alias uses upstream model name", model.Properties{OriginModelName: "h3", UpstreamModelName: "MiniMax-H3"}}, + {"legacy task falls back to origin model name", model.Properties{OriginModelName: "MiniMax-H3"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + model.DB.Exec("DELETE FROM tasks") + model.DB.Exec("DELETE FROM channels") + t.Cleanup(func() { + model.DB.Exec("DELETE FROM tasks") + model.DB.Exec("DELETE FROM channels") + }) + + const upstreamID = "upstream_001" + var mu sync.Mutex + var queryPaths []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + queryPaths = append(queryPaths, r.URL.Path) + mu.Unlock() + if !strings.HasPrefix(r.URL.Path, hailuo.QueryTaskV2Endpoint+"/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"task":{"id":"`+upstreamID+`","status":"succeeded","content":{"url":"https://cdn.example.com/h3.mp4"}}}`) + })) + defer srv.Close() + + ch := &model.Channel{ + Type: constant.ChannelTypeMiniMax, + Name: "minimax-h3-e2e", + Key: "sk-test", + Status: common.ChannelStatusEnabled, + } + ch.BaseURL = common.GetPointer(srv.URL) + require.NoError(t, model.DB.Create(ch).Error) + + task := &model.Task{ + TaskID: "task_public_1", + Platform: constant.TaskPlatform("35"), + UserId: 1, + ChannelId: ch.Id, + Action: constant.TaskActionGenerate, + Status: model.TaskStatusInProgress, + Progress: "30%", + Properties: tt.properties, + PrivateData: model.TaskPrivateData{ + UpstreamTaskID: upstreamID, + }, + } + require.NoError(t, model.DB.Create(task).Error) + + previousFactory := service.GetTaskAdaptorFunc + service.GetTaskAdaptorFunc = func(constant.TaskPlatform) service.TaskPollingAdaptor { return &hailuo.TaskAdaptor{} } + t.Cleanup(func() { service.GetTaskAdaptorFunc = previousFactory }) + + err := service.UpdateVideoTasks(context.Background(), constant.TaskPlatform("35"), map[int][]string{ + ch.Id: {upstreamID}, + }, map[string]*model.Task{task.GetUpstreamTaskID(): task}) + require.NoError(t, err) + + mu.Lock() + require.Equal(t, []string{hailuo.QueryTaskV2Endpoint + "/" + upstreamID}, queryPaths) + mu.Unlock() + + var updated model.Task + require.NoError(t, model.DB.First(&updated, task.ID).Error) + assert.Equal(t, string(model.TaskStatusSuccess), string(updated.Status)) + assert.Equal(t, "https://cdn.example.com/h3.mp4", updated.PrivateData.ResultURL) + }) + } +} diff --git a/service/task_polling.go b/service/task_polling.go index 250201ae0525..38639c31eb9f 100644 --- a/service/task_polling.go +++ b/service/task_polling.go @@ -459,9 +459,15 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * if privateData.Key != "" { key = privateData.Key } + // 轮询按上游(映射后)模型名选择查询端点;旧任务缺少上游模型名时回退到原始模型名。 + modelName := task.Properties.UpstreamModelName + if modelName == "" { + modelName = task.Properties.OriginModelName + } resp, err := adaptor.FetchTask(baseURL, key, map[string]any{ "task_id": task.GetUpstreamTaskID(), "action": task.Action, + "model": modelName, }, proxy) if err != nil { return fmt.Errorf("fetchTask failed for task %s: %w", taskId, err) From f1fd5ddb1a34f395bf49ad9fd465a839ecaff29e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E4=BA=91=E7=8C=AB?= Date: Sat, 15 Aug 2026 16:15:05 +0800 Subject: [PATCH 2/3] Delete service/hailuo_v2_e2e_test.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 清理多余测试 --- service/hailuo_v2_e2e_test.go | 100 ---------------------------------- 1 file changed, 100 deletions(-) delete mode 100644 service/hailuo_v2_e2e_test.go diff --git a/service/hailuo_v2_e2e_test.go b/service/hailuo_v2_e2e_test.go deleted file mode 100644 index 080dc9002c8f..000000000000 --- a/service/hailuo_v2_e2e_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package service_test - -import ( - "context" - "io" - "net/http" - "net/http/httptest" - "strings" - "sync" - "testing" - - "github.com/QuantumNous/new-api/common" - "github.com/QuantumNous/new-api/constant" - "github.com/QuantumNous/new-api/model" - "github.com/QuantumNous/new-api/relay/channel/task/hailuo" - "github.com/QuantumNous/new-api/service" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestUpdateVideoTasksV2EndToEnd 覆盖审核评论的核心场景:渠道模型映射别名 -// (h3 -> MiniMax-H3)提交的任务,轮询必须携带正确的模型名并打到 V2 查询端点, -// 任务完成后状态与结果 URL 正确落库。 -func TestUpdateVideoTasksV2EndToEnd(t *testing.T) { - tests := []struct { - name string - properties model.Properties - }{ - {"mapped alias uses upstream model name", model.Properties{OriginModelName: "h3", UpstreamModelName: "MiniMax-H3"}}, - {"legacy task falls back to origin model name", model.Properties{OriginModelName: "MiniMax-H3"}}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - model.DB.Exec("DELETE FROM tasks") - model.DB.Exec("DELETE FROM channels") - t.Cleanup(func() { - model.DB.Exec("DELETE FROM tasks") - model.DB.Exec("DELETE FROM channels") - }) - - const upstreamID = "upstream_001" - var mu sync.Mutex - var queryPaths []string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - mu.Lock() - queryPaths = append(queryPaths, r.URL.Path) - mu.Unlock() - if !strings.HasPrefix(r.URL.Path, hailuo.QueryTaskV2Endpoint+"/") { - http.NotFound(w, r) - return - } - w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, `{"task":{"id":"`+upstreamID+`","status":"succeeded","content":{"url":"https://cdn.example.com/h3.mp4"}}}`) - })) - defer srv.Close() - - ch := &model.Channel{ - Type: constant.ChannelTypeMiniMax, - Name: "minimax-h3-e2e", - Key: "sk-test", - Status: common.ChannelStatusEnabled, - } - ch.BaseURL = common.GetPointer(srv.URL) - require.NoError(t, model.DB.Create(ch).Error) - - task := &model.Task{ - TaskID: "task_public_1", - Platform: constant.TaskPlatform("35"), - UserId: 1, - ChannelId: ch.Id, - Action: constant.TaskActionGenerate, - Status: model.TaskStatusInProgress, - Progress: "30%", - Properties: tt.properties, - PrivateData: model.TaskPrivateData{ - UpstreamTaskID: upstreamID, - }, - } - require.NoError(t, model.DB.Create(task).Error) - - previousFactory := service.GetTaskAdaptorFunc - service.GetTaskAdaptorFunc = func(constant.TaskPlatform) service.TaskPollingAdaptor { return &hailuo.TaskAdaptor{} } - t.Cleanup(func() { service.GetTaskAdaptorFunc = previousFactory }) - - err := service.UpdateVideoTasks(context.Background(), constant.TaskPlatform("35"), map[int][]string{ - ch.Id: {upstreamID}, - }, map[string]*model.Task{task.GetUpstreamTaskID(): task}) - require.NoError(t, err) - - mu.Lock() - require.Equal(t, []string{hailuo.QueryTaskV2Endpoint + "/" + upstreamID}, queryPaths) - mu.Unlock() - - var updated model.Task - require.NoError(t, model.DB.First(&updated, task.ID).Error) - assert.Equal(t, string(model.TaskStatusSuccess), string(updated.Status)) - assert.Equal(t, "https://cdn.example.com/h3.mp4", updated.PrivateData.ResultURL) - }) - } -} From 97608830deafe256e5a8d90a5d64efc6fa663782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E4=BA=91=E7=8C=AB?= Date: Sat, 15 Aug 2026 16:16:10 +0800 Subject: [PATCH 3/3] Delete relay/channel/task/hailuo/e2e_test.go --- relay/channel/task/hailuo/e2e_test.go | 197 -------------------------- 1 file changed, 197 deletions(-) delete mode 100644 relay/channel/task/hailuo/e2e_test.go diff --git a/relay/channel/task/hailuo/e2e_test.go b/relay/channel/task/hailuo/e2e_test.go deleted file mode 100644 index 356784fb7d8c..000000000000 --- a/relay/channel/task/hailuo/e2e_test.go +++ /dev/null @@ -1,197 +0,0 @@ -package hailuo - -import ( - "io" - "net/http" - "net/http/httptest" - "strings" - "sync" - "testing" - - "github.com/QuantumNous/new-api/common" - "github.com/QuantumNous/new-api/model" - relaycommon "github.com/QuantumNous/new-api/relay/common" - "github.com/QuantumNous/new-api/relaykit/dto" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// v2E2ERecorder 记录 httptest 上游收到的创建/查询请求,供端到端断言使用。 -type v2E2ERecorder struct { - mu sync.Mutex - taskID string - createPath string - createBody []byte - createAuth string - createContentType string - queryPath string - queryAuth string -} - -// newV2E2EServer 模拟 MiniMax H3 V2 上游:创建任务 + 查询任务。 -func newV2E2EServer(t *testing.T, taskID string) (*httptest.Server, *v2E2ERecorder) { - t.Helper() - rec := &v2E2ERecorder{taskID: taskID} - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == http.MethodPost && r.URL.Path == VideoGenerationV2Endpoint: - body, _ := io.ReadAll(r.Body) - rec.mu.Lock() - rec.createPath = r.URL.Path - rec.createBody = body - rec.createAuth = r.Header.Get("Authorization") - rec.createContentType = r.Header.Get("Content-Type") - rec.mu.Unlock() - w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, `{"task_id":"`+taskID+`"}`) - case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, QueryTaskV2Endpoint+"/"): - rec.mu.Lock() - rec.queryPath = r.URL.Path - rec.queryAuth = r.Header.Get("Authorization") - rec.mu.Unlock() - w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, `{"task":{"id":"`+taskID+`","status":"succeeded","content":{"url":"https://cdn.example.com/h3.mp4"},"usage":{"total_seconds":5}}}`) - default: - http.NotFound(w, r) - } - })) - t.Cleanup(srv.Close) - return srv, rec -} - -func newV2E2EInfo(srv *httptest.Server, originModel string) *relaycommon.RelayInfo { - return &relaycommon.RelayInfo{ - TaskRelayInfo: &relaycommon.TaskRelayInfo{PublicTaskID: "task_pub_1"}, - OriginModelName: originModel, - ChannelMeta: &relaycommon.ChannelMeta{ - ApiKey: "sk-test", - ChannelBaseUrl: srv.URL, - UpstreamModelName: "MiniMax-H3", - }, - } -} - -// TestEndToEndV2SubmitPollConvert 覆盖完整链路:映射别名校验 -> 真实 HTTP 提交 -> -// 响应解析 -> 轮询 -> 任务结果解析 -> OpenAI 视频格式转换。 -func TestEndToEndV2SubmitPollConvert(t *testing.T) { - const upstreamTaskID = "upstream_001" - srv, rec := newV2E2EServer(t, upstreamTaskID) - - c := newV2TestContext(t, `{"model":"h3","prompt":"a boy playing basketball"}`) - c.Set("model_mapping", `{"h3":"MiniMax-H3"}`) - info := newV2E2EInfo(srv, "h3") - - adaptor := &TaskAdaptor{} - adaptor.Init(info) - - // 1. 校验:渠道模型映射别名 h3 -> MiniMax-H3 必须走 V2 校验。 - taskErr := adaptor.ValidateRequestAndSetAction(c, info) - require.Nil(t, taskErr) - - // 2. 构建请求体并真实提交。 - body, err := adaptor.BuildRequestBody(c, info) - require.NoError(t, err) - resp, err := adaptor.DoRequest(c, info, body) - require.NoError(t, err) - defer resp.Body.Close() - require.Equal(t, http.StatusOK, resp.StatusCode) - - // 3. 断言提交请求:路径、认证头、请求体形状。 - rec.mu.Lock() - require.Equal(t, VideoGenerationV2Endpoint, rec.createPath) - require.Equal(t, "Bearer sk-test", rec.createAuth) - require.Equal(t, "application/json", rec.createContentType) - createBody := append([]byte(nil), rec.createBody...) - rec.mu.Unlock() - - var payload VideoGenerationV2Request - require.NoError(t, common.Unmarshal(createBody, &payload)) - assert.Equal(t, "MiniMax-H3", payload.Model) - assert.Equal(t, Resolution768P, payload.Resolution) - assert.Equal(t, V2DefaultDuration, payload.Duration) - assert.Equal(t, V2DefaultRatio, payload.Ratio) - require.Len(t, payload.Content, 1) - assert.Equal(t, "text", payload.Content[0].Type) - assert.Equal(t, "a boy playing basketball", payload.Content[0].Text) - - // 4. DoResponse 解析出上游 task_id 并写 OpenAI 格式响应。 - taskID, taskData, taskErr := adaptor.DoResponse(c, resp, info) - require.Nil(t, taskErr) - assert.Equal(t, upstreamTaskID, taskID) - require.NotEmpty(t, taskData) - - // 5. 轮询:按映射后的模型名打到 V2 查询端点。 - pollResp, err := adaptor.FetchTask(srv.URL, "sk-test", map[string]any{ - "task_id": taskID, - "model": "MiniMax-H3", - }, "") - require.NoError(t, err) - defer pollResp.Body.Close() - pollBody, err := io.ReadAll(pollResp.Body) - require.NoError(t, err) - - rec.mu.Lock() - require.Equal(t, QueryTaskV2Endpoint+"/"+upstreamTaskID, rec.queryPath) - require.Equal(t, "Bearer sk-test", rec.queryAuth) - rec.mu.Unlock() - - // 6. 解析查询结果为任务终态 + URL。 - ti, err := adaptor.ParseTaskResult(pollBody) - require.NoError(t, err) - assert.Equal(t, string(model.TaskStatusSuccess), string(ti.Status)) - assert.Equal(t, "https://cdn.example.com/h3.mp4", ti.Url) - - // 7. 转换为最终 OpenAI 视频格式。 - task := &model.Task{ - TaskID: "task_pub_1", - Status: model.TaskStatusSuccess, - Properties: model.Properties{OriginModelName: "h3"}, - Data: pollBody, - } - ovData, err := adaptor.ConvertToOpenAIVideo(task) - require.NoError(t, err) - var ov dto.OpenAIVideo - require.NoError(t, common.Unmarshal(ovData, &ov)) - assert.Equal(t, dto.VideoStatusCompleted, ov.Status) -} - -// TestEndToEndV2SubmitFirstLastFrame 覆盖图生视频(首尾帧 + 2K)提交链路: -// content 三项、ratio 自适应为 adaptive、resolution 归一化为 2K。 -func TestEndToEndV2SubmitFirstLastFrame(t *testing.T) { - srv, rec := newV2E2EServer(t, "upstream_002") - - c := newV2TestContext(t, `{"model":"MiniMax-H3","prompt":"smooth camera move","duration":5,"size":"2K","images":["first.png","last.png"]}`) - info := newV2E2EInfo(srv, "MiniMax-H3") - - adaptor := &TaskAdaptor{} - adaptor.Init(info) - - taskErr := adaptor.ValidateRequestAndSetAction(c, info) - require.Nil(t, taskErr) - - body, err := adaptor.BuildRequestBody(c, info) - require.NoError(t, err) - resp, err := adaptor.DoRequest(c, info, body) - require.NoError(t, err) - defer resp.Body.Close() - require.Equal(t, http.StatusOK, resp.StatusCode) - - rec.mu.Lock() - require.Equal(t, VideoGenerationV2Endpoint, rec.createPath) - createBody := append([]byte(nil), rec.createBody...) - rec.mu.Unlock() - - var payload VideoGenerationV2Request - require.NoError(t, common.Unmarshal(createBody, &payload)) - assert.Equal(t, "2K", payload.Resolution) - assert.Equal(t, "adaptive", payload.Ratio) - require.Len(t, payload.Content, 3) - assert.Equal(t, "text", payload.Content[0].Type) - assert.Equal(t, "first_frame", payload.Content[1].Role) - assert.Equal(t, "first.png", payload.Content[1].ImageURL.URL) - assert.Equal(t, "last_frame", payload.Content[2].Role) - assert.Equal(t, "last.png", payload.Content[2].ImageURL.URL) - - _, _, taskErr = adaptor.DoResponse(c, resp, info) - require.Nil(t, taskErr) -}