新增MiniMax海螺视频模型支持 - #2225
Conversation
WalkthroughThis PR introduces integration for the hailuo video generation channel by adding a complete TaskAdaptor implementation with request/response handling, propagating API keys into task initialization, and implementing Task-to-OpenAI video format conversion alongside supporting data models and constants. Changes
Sequence DiagramsequenceDiagram
participant Client
participant Controller as controller/task_video.go
participant Adaptor as hailuo/adaptor.go
participant HailuoAPI as Hailuo API
participant DB as Task DB
Client->>Controller: Video generation request
Controller->>Controller: Populate RelayInfo.ApiKey from channel
Controller->>Adaptor: Init(RelayInfo)
Adaptor->>Adaptor: Store apiKey, baseURL
Controller->>Adaptor: ValidateRequestAndSetAction()
Adaptor-->>Controller: ✓ or TaskError
Controller->>Adaptor: BuildRequestURL()
Adaptor-->>Controller: API endpoint URL
Controller->>Adaptor: BuildRequestHeader()
Adaptor-->>Controller: Headers (auth, content-type)
Controller->>Adaptor: BuildRequestBody()
Adaptor->>Adaptor: convertToRequestPayload()
Adaptor-->>Controller: Request JSON payload
Controller->>Adaptor: DoRequest()
Adaptor->>HailuoAPI: POST /v1/video_generation
HailuoAPI-->>Adaptor: Response + TaskID
Controller->>Adaptor: DoResponse()
Adaptor->>Adaptor: ParseTaskResult()
Adaptor-->>Controller: TaskID, TaskData
Controller->>DB: Store task
Note over Controller,Adaptor: Later: Task status polling
Controller->>Adaptor: FetchTask()
Adaptor->>HailuoAPI: GET /v1/query/video_generation
HailuoAPI-->>Adaptor: Status, FileID
Adaptor->>Adaptor: ParseTaskResult() + buildVideoURL()
Adaptor-->>Controller: TaskInfo with status/URL
Controller->>Adaptor: ConvertToOpenAIVideo()
Adaptor-->>Controller: OpenAI video response
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes
Possibly related PRs
Suggested reviewers
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (2)
model/task.go (1)
432-442: PopulateTaskIDin OpenAIVideo for backward compatibility
ToOpenAIVideocurrently only setsID, leavingTaskIDempty even though it exists for legacy clients. To align with existing create responses (e.g. hailuoDoResponse) and ease migration, consider also settingTaskID:func (t *Task) ToOpenAIVideo() *dto.OpenAIVideo { openAIVideo := dto.NewOpenAIVideo() openAIVideo.ID = t.TaskID + openAIVideo.TaskID = t.TaskID openAIVideo.Status = t.Status.ToVideoStatus() openAIVideo.Model = t.Properties.OriginModelName openAIVideo.SetProgressStr(t.Progress) openAIVideo.CreatedAt = t.CreatedAt openAIVideo.CompletedAt = t.UpdatedAt openAIVideo.SetMetadata("url", t.FailReason) return openAIVideo }Optionally, you could later gate
CompletedAt/metadata["url"]to success states only, but that’s not blocking.relay/channel/task/hailuo/models.go (1)
1-170: Model and config definitions look good; consider hoisting configs mapThe type definitions closely follow the API schema and the
GetModelConfigfallback behavior is sensible.If
GetModelConfigis called frequently, you might want to move theconfigsmap to a package‑levelvarto avoid reallocating it on every call:-var ModelConfig map[string]ModelConfig // example - -func GetModelConfig(model string) ModelConfig { - configs := map[string]ModelConfig{ - // ... - } +var modelConfigs = map[string]ModelConfig{ + // ... +} + +func GetModelConfig(model string) ModelConfig { - if config, exists := configs[model]; exists { + if config, exists := modelConfigs[model]; exists { return config } // ... }This is an optimization only; current implementation is functionally correct.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (7)
controller/task_video.go(1 hunks)model/task.go(1 hunks)relay/channel/task/hailuo/adaptor.go(1 hunks)relay/channel/task/hailuo/constants.go(1 hunks)relay/channel/task/hailuo/models.go(1 hunks)relay/common/relay_info.go(2 hunks)relay/relay_adaptor.go(2 hunks)
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-08-26T09:59:00.337Z
Learnt from: Sh1n3zZ
Repo: QuantumNous/new-api PR: 1659
File: relay/relay_task.go:285-305
Timestamp: 2025-08-26T09:59:00.337Z
Learning: In controller/task_video.go, the redactVideoResponseBody function sanitizes video task responses by removing bytesBase64Encoded fields and truncating base64 strings to 256 characters to prevent large binary data from being stored in task.Data.
Applied to files:
relay/channel/task/hailuo/adaptor.go
📚 Learning: 2025-08-26T09:59:00.337Z
Learnt from: Sh1n3zZ
Repo: QuantumNous/new-api PR: 1659
File: relay/relay_task.go:285-305
Timestamp: 2025-08-26T09:59:00.337Z
Learning: In controller/task_video.go, data: URLs (containing base64 encoded video data) are prevented from being stored in task.FailReason by checking if the URL starts with "data:" before assignment. This same pattern should be applied consistently across the codebase.
Applied to files:
relay/channel/task/hailuo/adaptor.go
🧬 Code graph analysis (5)
model/task.go (1)
dto/openai_video.go (2)
OpenAIVideo(16-31)NewOpenAIVideo(43-47)
relay/channel/task/hailuo/models.go (1)
relay/channel/task/hailuo/constants.go (5)
DefaultResolution(51-51)Resolution768P(45-45)Resolution1080P(46-46)Resolution512P(43-43)Resolution720P(44-44)
relay/common/relay_info.go (2)
relay/common/relay_utils.go (1)
HasImage(21-23)common/json.go (2)
Marshal(21-23)Unmarshal(9-11)
relay/relay_adaptor.go (3)
constant/channel.go (1)
ChannelTypeMiniMax(35-35)relay/channel/task/hailuo/adaptor.go (1)
TaskAdaptor(26-30)relay/channel/adapter.go (1)
TaskAdaptor(34-53)
relay/channel/task/hailuo/adaptor.go (9)
relay/common/relay_info.go (2)
RelayInfo(76-123)TaskSubmitReq(488-499)relay/common/relay_utils.go (1)
ValidateBasicTaskRequest(204-228)common/json.go (2)
Marshal(21-23)Unmarshal(9-11)relay/channel/api_request.go (1)
DoTaskApiRequest(301-323)service/error.go (1)
TaskErrorWrapper(140-157)relay/channel/task/hailuo/models.go (7)
VideoResponse(22-25)BaseResp(27-30)VideoRequest(8-20)GetModelConfig(82-170)ModelConfig(59-66)QueryTaskResponse(36-43)RetrieveFileResponse(68-71)dto/openai_video.go (2)
NewOpenAIVideo(43-47)OpenAIVideoError(49-52)service/http_client.go (1)
GetHttpClient(49-51)model/task.go (6)
TaskStatusFailure(37-37)TaskStatusInProgress(36-36)TaskStatusSuccess(38-38)Task(42-63)Task(297-301)Task(303-307)
🔇 Additional comments (4)
controller/task_video.go (1)
51-56: API key propagation into RelayInfo looks correctSetting
info.ApiKey = cacheGetChannel.Keyafter initializingChannelMetacorrectly exposes the channel key to task adaptors via promoted fields and is required for hailuo’sInit. No further changes needed here.relay/common/relay_info.go (1)
501-507: TaskSubmitReq helpers are fine; callsites must pass proper pointersThe pointer receivers on
GetPrompt/HasImageand the newUnmarshalMetadataimplementation are reasonable.UnmarshalMetadatacorrectly treats nil metadata as a no-op and reusesencoding/jsonfor map→struct conversion.Callers should pass a pointer to the target struct (e.g.
req.UnmarshalMetadata(videoReq)) rather than a pointer-to-pointer to avoid unexpected behavior; the hailuo adaptor currently needs that adjustment (see hailuo/adaptor.go).Also applies to: 540-553
relay/relay_adaptor.go (1)
35-35: MiniMax → hailuo TaskAdaptor wiring looks correctImporting the hailuo package and mapping
ChannelTypeMiniMaxto&hailuo.TaskAdaptor{}inGetTaskAdaptoris consistent with the existing pattern and correctly routes MiniMax video tasks to the new channel.Also applies to: 157-158
relay/channel/task/hailuo/constants.go (1)
1-52: Channel constants and model list are coherentThe channel name, model list, endpoints, status codes, task states, and resolution/default constants are well-structured and align with how the adaptor and models consume them. No issues here.
| func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) { | ||
| responseBody, err := io.ReadAll(resp.Body) | ||
| if err != nil { | ||
| taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) | ||
| return | ||
| } | ||
| _ = resp.Body.Close() | ||
|
|
||
| var hResp VideoResponse | ||
| if err := json.Unmarshal(responseBody, &hResp); err != nil { | ||
| taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError) | ||
| return | ||
| } | ||
|
|
||
| if hResp.BaseResp.StatusCode != StatusSuccess { | ||
| taskErr = service.TaskErrorWrapper( | ||
| fmt.Errorf("hailuo api error: %s", hResp.BaseResp.StatusMsg), | ||
| strconv.Itoa(hResp.BaseResp.StatusCode), | ||
| http.StatusBadRequest, | ||
| ) | ||
| return | ||
| } | ||
|
|
||
| ov := dto.NewOpenAIVideo() | ||
| ov.ID = hResp.TaskID | ||
| ov.TaskID = hResp.TaskID | ||
| ov.CreatedAt = time.Now().Unix() | ||
| ov.Model = info.OriginModelName | ||
|
|
||
| c.JSON(http.StatusOK, ov) | ||
| return hResp.TaskID, responseBody, nil | ||
| } |
There was a problem hiding this comment.
Set initial video status/progress in DoResponse
The create handler returns an OpenAIVideo without Status or progress populated, which is inconsistent with the OpenAI‑style contract (clients typically expect "queued" right after creation). You already have a VideoStatusQueued constant via dto.
Consider initializing these fields:
ov := dto.NewOpenAIVideo()
ov.ID = hResp.TaskID
ov.TaskID = hResp.TaskID
ov.CreatedAt = time.Now().Unix()
ov.Model = info.OriginModelName
+ov.Status = dto.VideoStatusQueued
+ov.SetProgressStr("0%")This makes the creation response more useful without affecting downstream polling.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) { | |
| responseBody, err := io.ReadAll(resp.Body) | |
| if err != nil { | |
| taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) | |
| return | |
| } | |
| _ = resp.Body.Close() | |
| var hResp VideoResponse | |
| if err := json.Unmarshal(responseBody, &hResp); err != nil { | |
| taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError) | |
| return | |
| } | |
| if hResp.BaseResp.StatusCode != StatusSuccess { | |
| taskErr = service.TaskErrorWrapper( | |
| fmt.Errorf("hailuo api error: %s", hResp.BaseResp.StatusMsg), | |
| strconv.Itoa(hResp.BaseResp.StatusCode), | |
| http.StatusBadRequest, | |
| ) | |
| return | |
| } | |
| ov := dto.NewOpenAIVideo() | |
| ov.ID = hResp.TaskID | |
| ov.TaskID = hResp.TaskID | |
| ov.CreatedAt = time.Now().Unix() | |
| ov.Model = info.OriginModelName | |
| c.JSON(http.StatusOK, ov) | |
| return hResp.TaskID, responseBody, nil | |
| } | |
| func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) { | |
| responseBody, err := io.ReadAll(resp.Body) | |
| if err != nil { | |
| taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) | |
| return | |
| } | |
| _ = resp.Body.Close() | |
| var hResp VideoResponse | |
| if err := json.Unmarshal(responseBody, &hResp); err != nil { | |
| taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError) | |
| return | |
| } | |
| if hResp.BaseResp.StatusCode != StatusSuccess { | |
| taskErr = service.TaskErrorWrapper( | |
| fmt.Errorf("hailuo api error: %s", hResp.BaseResp.StatusMsg), | |
| strconv.Itoa(hResp.BaseResp.StatusCode), | |
| http.StatusBadRequest, | |
| ) | |
| return | |
| } | |
| ov := dto.NewOpenAIVideo() | |
| ov.ID = hResp.TaskID | |
| ov.TaskID = hResp.TaskID | |
| ov.CreatedAt = time.Now().Unix() | |
| ov.Model = info.OriginModelName | |
| ov.Status = dto.VideoStatusQueued | |
| ov.SetProgressStr("0%") | |
| c.JSON(http.StatusOK, ov) | |
| return hResp.TaskID, responseBody, nil | |
| } |
🤖 Prompt for AI Agents
In relay/channel/task/hailuo/adaptor.go around lines 80 to 111, the created
OpenAIVideo is missing initial status/progress; set ov.Status =
dto.VideoStatusQueued and set ov.Progress = 0 (and optionally ov.UpdatedAt =
ov.CreatedAt if that field exists) before sending the JSON response so the
creation response follows the OpenAI‑style contract and signals the video is
queued.
| func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*VideoRequest, error) { | ||
| modelConfig := GetModelConfig(req.Model) | ||
| duration := DefaultDuration | ||
| if req.Duration > 0 { | ||
| duration = req.Duration | ||
| } | ||
| resolution := modelConfig.DefaultResolution | ||
| if req.Size != "" { | ||
| resolution = a.parseResolutionFromSize(req.Size, modelConfig) | ||
| } | ||
|
|
||
| videoRequest := &VideoRequest{ | ||
| Model: req.Model, | ||
| Prompt: req.Prompt, | ||
| Duration: &duration, | ||
| Resolution: resolution, | ||
| } | ||
| if err := req.UnmarshalMetadata(&videoRequest); err != nil { | ||
| return nil, errors.Wrap(err, "unmarshal metadata to video request failed") | ||
| } | ||
|
|
||
| return videoRequest, nil | ||
| } |
There was a problem hiding this comment.
Fix metadata unmarshal target in convertToRequestPayload
convertToRequestPayload currently calls:
if err := req.UnmarshalMetadata(&videoRequest); err != nil {
// ...
}Here videoRequest is already a *VideoRequest, so &videoRequest is a **VideoRequest. This will cause encoding/json to treat the target as a pointer-to-pointer, which can overwrite the pointer itself and/or lose your pre‑filled defaults (model, duration, resolution) instead of overlaying metadata onto them.
You want to unmarshal into the struct that videoRequest points to:
func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*VideoRequest, error) {
modelConfig := GetModelConfig(req.Model)
duration := DefaultDuration
if req.Duration > 0 {
duration = req.Duration
}
resolution := modelConfig.DefaultResolution
if req.Size != "" {
resolution = a.parseResolutionFromSize(req.Size, modelConfig)
}
videoRequest := &VideoRequest{
Model: req.Model,
Prompt: req.Prompt,
Duration: &duration,
Resolution: resolution,
}
- if err := req.UnmarshalMetadata(&videoRequest); err != nil {
+ if err := req.UnmarshalMetadata(videoRequest); err != nil {
return nil, errors.Wrap(err, "unmarshal metadata to video request failed")
}
return videoRequest, nil
}This preserves the defaults while allowing metadata to override fields where present.
🤖 Prompt for AI Agents
In relay/channel/task/hailuo/adaptor.go around lines 140 to 162, the call to
req.UnmarshalMetadata currently passes &videoRequest (a **VideoRequest) which
causes JSON unmarshal to target a pointer-to-pointer and can overwrite the
pointer (losing prefilled defaults); change the call to pass videoRequest (the
*VideoRequest) so UnmarshalMetadata decodes into the struct pointed to and
overlays metadata onto the existing defaults, keeping the existing error
wrapping logic unchanged.
| func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { | ||
| resTask := QueryTaskResponse{} | ||
| if err := json.Unmarshal(respBody, &resTask); err != nil { | ||
| return nil, errors.Wrap(err, "unmarshal task result failed") | ||
| } | ||
|
|
||
| taskResult := relaycommon.TaskInfo{} | ||
|
|
||
| if resTask.BaseResp.StatusCode == StatusSuccess { | ||
| taskResult.Code = 0 | ||
| } else { | ||
| taskResult.Code = resTask.BaseResp.StatusCode | ||
| taskResult.Reason = resTask.BaseResp.StatusMsg | ||
| taskResult.Status = model.TaskStatusFailure | ||
| taskResult.Progress = "100%" | ||
| } | ||
|
|
||
| switch resTask.Status { | ||
| case TaskStatusPreparing, TaskStatusQueueing, TaskStatusProcessing: | ||
| taskResult.Status = model.TaskStatusInProgress | ||
| taskResult.Progress = "30%" | ||
| if resTask.Status == TaskStatusProcessing { | ||
| taskResult.Progress = "50%" | ||
| } | ||
| case TaskStatusSuccess: | ||
| taskResult.Status = model.TaskStatusSuccess | ||
| taskResult.Progress = "100%" | ||
| taskResult.Url = a.buildVideoURL(resTask.TaskID, resTask.FileID) | ||
| case TaskStatusFailed: | ||
| taskResult.Status = model.TaskStatusFailure | ||
| taskResult.Progress = "100%" | ||
| if taskResult.Reason == "" { | ||
| taskResult.Reason = "task failed" | ||
| } | ||
| default: | ||
| taskResult.Status = model.TaskStatusInProgress | ||
| taskResult.Progress = "30%" | ||
| } | ||
|
|
||
| return &taskResult, nil | ||
| } |
There was a problem hiding this comment.
Tighten error handling in ParseTaskResult and note URL retrieval behavior
- BaseResp error handling can be overridden
When resTask.BaseResp.StatusCode != StatusSuccess, you set failure status and Progress = "100%", but then unconditionally run the switch resTask.Status, which can downgrade a failed task back to IN_PROGRESS:
if resTask.BaseResp.StatusCode == StatusSuccess {
taskResult.Code = 0
} else {
taskResult.Code = resTask.BaseResp.StatusCode
taskResult.Reason = resTask.BaseResp.StatusMsg
taskResult.Status = model.TaskStatusFailure
taskResult.Progress = "100%"
}Then the switch may overwrite Status/Progress. To avoid stuck tasks on upstream errors, return early on non‑success:
if resTask.BaseResp.StatusCode == StatusSuccess {
taskResult.Code = 0
} else {
taskResult.Code = resTask.BaseResp.StatusCode
taskResult.Reason = resTask.BaseResp.StatusMsg
taskResult.Status = model.TaskStatusFailure
taskResult.Progress = "100%"
+ return &taskResult, nil
}- Video URL retrieval behavior
buildVideoURL does a secondary call to /v1/files/retrieve and returns DownloadURL, which is then stored in taskResult.Url. This is consistent with the existing pattern where the controller copies non‑data: URLs into task.FailReason and redacts raw binary/base64 from task data, so you’re staying aligned with prior redaction guidance. Based on learnings.
Given this, the main change needed here is the early return on non‑success StatusCode.
Also applies to: 243-279
🤖 Prompt for AI Agents
In relay/channel/task/hailuo/adaptor.go around lines 179 to 219 (and similarly
apply to 243-279), the ParseTaskResult function sets failure fields when
resTask.BaseResp.StatusCode != StatusSuccess but then continues to a switch on
resTask.Status that can overwrite Status/Progress; change the control flow to
return early on non-success BaseResp by constructing and returning the TaskInfo
immediately after setting Code, Reason, Status=model.TaskStatusFailure and
Progress="100%" so the downstream switch cannot downgrade the error state;
ensure the early return preserves Code and Reason and does not attempt to
build/assign the video URL when the base response indicates failure.
新增MiniMax海螺视频模型支持
官方文档:
https://platform.minimaxi.com/docs/api-reference/video-generation-intro支持模型:MiniMax-Hailuo-2.3, MiniMax-Hailuo-02(首尾帧生视频), S2V-01(参考生视频)请求示例:请求:
请求:
Summary by CodeRabbit
Release Notes
New Features
Improvements