feat(model):新增MiniMax-H3视频支持

This commit is contained in:
qian committed 2026-09-13 22:14:21 +08:00
1 parent 26c53c2228
commit 5ce16daa46
29 files changed
+3736 -98

No files matched your search

+329
View File
@@ -0,0 +1,329 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"github.com/gogf/gf/v2/frame/g"
"github.com/qianfree/team-api/relay/common"
"github.com/qianfree/team-api/relay/constant"
taskminimax "github.com/qianfree/team-api/relay/taskchannel/minimax"
)
// MiniMax 官方视频协议(/v2)核心处理:查询回放、任务取消。
// 协议规格见 docs/modeldocs/minimax/video/(02-端点参考.md、04-响应对象.md)。
// 状态机:queued/running/succeeded/failed/cancelled(网关不主动产生 cancelled 任务;
// 上游任务被取消时经轮询映射为 FAILURE,retrieve 回放为 failed)。
// 错误用 OpenAI 风格 OaiError(writeTaskError 输出同构,官方 v2 即此风格)。
//
// DELETE 端点语义(有意与官方分歧):
// - 仅支持取消 queued 任务(取消结果以远程返回为准,上游确认才生效);
// - 不支持删除任务记录(官方对终态任务的 delete 记录能力网关不提供,软删也不做),
// 网关保留任务数据用于计费与审计;
// - 任务终态后 content.url 不再回源刷新(回放缓存的上游直链,有时效)。
// minimaxStatusFromTask 平台任务状态 → 官方状态机映射
func minimaxStatusFromTask(status string) string {
switch status {
case "IN_PROGRESS":
return "running"
case "SUCCESS":
return "succeeded"
case "FAILURE":
return "failed"
default:
// NOT_START / SUBMITTED / QUEUED 及一切未知中间态都归入 queued
return "queued"
}
}
// isMiniMaxTerminalTask 判断是否终态(succeeded/failed 对应的平台态)
func isMiniMaxTerminalTask(status string) bool {
return status == string(common.TaskStatusSuccess) || status == string(common.TaskStatusFailure)
}
// parseMiniMaxUpstreamTask 解析 task.Data 中缓存的上游单任务查询响应。
// 提交后首拍轮询前 Data 是提交响应 {"task_id":...} 形态,解析不出 task 时返回 nil(静默降级)。
func parseMiniMaxUpstreamTask(data []byte) *taskminimax.VideoTask {
if len(data) == 0 {
return nil
}
var resp taskminimax.TaskQueryResponse
if err := json.Unmarshal(data, &resp); err != nil || resp.Task == nil {
return nil
}
return resp.Task
}
// buildMiniMaxVideoTask 平台任务 → 官方 VideoTask 对象。
// 任务记录是 id/status/model/时间戳的真相源;usage/resolution/duration/ratio 从
// 缓存的上游查询响应(task.Data)富集,不可解析时缺省不输出。
func buildMiniMaxVideoTask(task *common.AsyncTask) *taskminimax.VideoTask {
obj := &taskminimax.VideoTask{
ID: task.PublicTaskID,
Model: task.ModelName,
Status: minimaxStatusFromTask(task.Status),
CreatedAt: task.CreatedAt.Unix(),
UpdatedAt: task.UpdatedAt.Unix(),
TaskType: "generation",
Modality: "video",
}
if obj.Status == "failed" {
message := task.FailReason
if message == "" {
message = "video generation failed"
}
obj.Error = &taskminimax.VideoTaskError{Code: "video_generation_failed", Message: message}
}
if task.ResultURL != "" {
obj.Content = &taskminimax.VideoTaskContent{URL: task.ResultURL}
}
if upstream := parseMiniMaxUpstreamTask(task.Data); upstream != nil {
if upstream.Usage != nil {
obj.Usage = upstream.Usage
}
if upstream.Resolution != "" {
obj.Resolution = upstream.Resolution
}
if upstream.Duration > 0 {
obj.Duration = upstream.Duration
}
if upstream.Ratio != "" {
obj.Ratio = upstream.Ratio
}
// 成品直链以任务记录的 ResultURL 为准(轮询结算时写入),上游回放仅兜底
if obj.Content == nil && upstream.Content != nil {
obj.Content = upstream.Content
}
}
return obj
}
// writeMiniMaxSubmitResponse 提交成功响应:官方 VideoGenerationV2Resp 形态
func writeMiniMaxSubmitResponse(w http.ResponseWriter, publicTaskID string) {
writeJSON(w, http.StatusOK, map[string]any{"task_id": publicTaskID})
}
// fetchMiniMaxTaskById 查询任务并做租户双键隔离 + 平台门禁。
// 平台门禁:/v2 端点只服务 minimax 平台任务,防止异构平台任务 ID 被塞进官方响应结构回放。
func fetchMiniMaxTaskById(
ctx context.Context,
taskID string,
rc *TaskRelayContext,
dataProvider common.TaskDataProvider,
) (*common.AsyncTask, bool) {
task, err := dataProvider.GetTaskByPublicIDAndUser(ctx, taskID, rc.UserID, rc.TenantID)
if err != nil {
g.Log().Errorf(ctx, "MiniMaxVideo: query task failed, taskID=%s, err=%v", taskID, err)
writeTaskError(rc.Writer, http.StatusInternalServerError, "query task failed", "")
return nil, false
}
if task == nil || task.Platform != string(constant.TaskPlatformMiniMax) {
writeTaskError(rc.Writer, http.StatusNotFound, "No MiniMax video task found with id '"+taskID+"'.", "")
return nil, false
}
return task, true
}
// HandleMiniMaxVideoRetrieve GET /v2/query/video_generation/{task_id} 查询管线。
// 响应为官方 GetVideoGenerationV2Resp 形态({"task": {...}} 外层包装)。
func HandleMiniMaxVideoRetrieve(
ctx context.Context,
taskID string,
rc *TaskRelayContext,
dataProvider common.TaskDataProvider,
) {
task, ok := fetchMiniMaxTaskById(ctx, taskID, rc, dataProvider)
if !ok {
return
}
writeJSON(rc.Writer, http.StatusOK, taskminimax.TaskQueryResponse{Task: buildMiniMaxVideoTask(task)})
}
// HandleMiniMaxVideoCancel DELETE /v2/video_generation/{task_id} 取消管线。
// 仅支持取消排队中的任务,不支持删除任务记录(含软删,网关保留计费与审计数据)。
//
// 取消结果以远程返回为准:调用上游 DELETE,上游确认 action=cancelled 才向客户端
// 返回成功;上游拒绝(如任务已进入 running)时原样透传上游错误。
// 本地任务状态不在此处改写——轮询循环下一拍从上游查询到 cancelled → FAILURE → 退款,
// 预扣退款与活跃任务计数等记账全部走既有轮询链路,避免在本端点重复实现结算。
func HandleMiniMaxVideoCancel(
ctx context.Context,
taskID string,
rc *TaskRelayContext,
dataProvider common.TaskDataProvider,
) {
task, ok := fetchMiniMaxTaskById(ctx, taskID, rc, dataProvider)
if !ok {
return
}
// 终态任务既不可取消也不可删除(删除能力网关不提供)
if isMiniMaxTerminalTask(task.Status) {
writeTaskError(rc.Writer, http.StatusBadRequest,
"task is already completed; only queued tasks can be cancelled", "task_not_cancellable")
return
}
// 运行中的任务上游不允许取消,前置拦截省一次上游调用(本地状态若滞后,由上游裁决兜底)
if task.Status == string(common.TaskStatusInProgress) {
writeTaskError(rc.Writer, http.StatusBadRequest, "running tasks cannot be cancelled", "task_not_cancellable")
return
}
// v1(Hailuo 系列)上游没有取消端点
upstreamModel := task.UpstreamModel
if upstreamModel == "" {
upstreamModel = task.ModelName
}
if !taskminimax.IsV2Model(upstreamModel) {
writeTaskError(rc.Writer, http.StatusBadRequest,
"v1 video tasks cannot be cancelled; the MiniMax v1 API has no cancel endpoint", "task_not_cancellable")
return
}
// 提取上游任务 ID
var pd struct {
UpstreamTaskID string `json:"upstream_task_id"`
}
if err := json.Unmarshal(task.PrivateData, &pd); err != nil || pd.UpstreamTaskID == "" {
g.Log().Errorf(ctx, "MiniMaxVideoCancel: invalid private data, taskID=%s, err=%v", taskID, err)
writeTaskError(rc.Writer, http.StatusInternalServerError, "invalid task data", "")
return
}
// 取渠道信息(上游地址与密钥),对齐 HandleMjImageProxy 的任务→渠道回查模式
channel, err := dataProvider.GetChannelByID(ctx, task.ChannelID)
if err != nil || channel == nil {
g.Log().Errorf(ctx, "MiniMaxVideoCancel: channel not found, channelID=%d, err=%v", task.ChannelID, err)
writeTaskError(rc.Writer, http.StatusInternalServerError, "channel not found", "")
return
}
result, taskErr := taskminimax.CancelTask(channel.BaseURL, channel.ApiKey, pd.UpstreamTaskID, parseMiniMaxUseProxy(channel.Settings))
if taskErr != nil {
// 远程取消失败(含上游判定运行中/已结束不可取消):以远程结论为准
g.Log().Infof(ctx, "MiniMaxVideoCancel: upstream rejected, taskID=%s, status=%d, message=%q", taskID, taskErr.StatusCode, taskErr.Message)
writeTaskError(rc.Writer, taskErr.StatusCode, taskErr.Message, taskErr.ErrCode)
return
}
// 防御竞态:本地检查后任务已在上游完成时,上游会执行删记录(action=deleted)而非取消
if result.Action != "cancelled" {
g.Log().Infof(ctx, "MiniMaxVideoCancel: upstream action=%s (not cancelled), taskID=%s", result.Action, taskID)
writeTaskError(rc.Writer, http.StatusBadRequest,
"task is no longer queued; only queued tasks can be cancelled", "task_not_cancellable")
return
}
g.Log().Infof(ctx, "MiniMaxVideoCancel: task cancelled upstream, taskID=%s, upstreamTaskID=%s, tenant=%d, user=%d",
taskID, pd.UpstreamTaskID, rc.TenantID, rc.UserID)
// 官方 DeleteVideoGenerationV2Resp 形态(取消分支)
writeJSON(rc.Writer, http.StatusOK, map[string]any{
"task_id": task.PublicTaskID,
"action": "cancelled",
"status": "cancelled",
})
}
// parseMiniMaxUseProxy 从渠道 settings JSONB 解析代理开关
func parseMiniMaxUseProxy(settings json.RawMessage) bool {
var s struct {
UseProxy bool `json:"use_proxy"`
}
if len(settings) > 0 {
_ = json.Unmarshal(settings, &s)
}
return s.UseProxy
}
// ==================== v1(Hailuo 系列)官方协议 ====================
// minimaxV1StatusFromTask 平台任务状态 → v1 官方状态机映射
// (Preparing/Queueing/Processing/Success/Fail)
func minimaxV1StatusFromTask(status string) string {
switch status {
case "QUEUED":
return "Queueing"
case "IN_PROGRESS":
return "Processing"
case "SUCCESS":
return "Success"
case "FAILURE":
return "Fail"
default:
// NOT_START / SUBMITTED 及未知早期状态
return "Preparing"
}
}
// writeMiniMaxV1SubmitResponse v1 提交成功响应:官方 VideoGenerationResp 形态
// (含 base_resp 成功信封)
func writeMiniMaxV1SubmitResponse(w http.ResponseWriter, publicTaskID string) {
writeJSON(w, http.StatusOK, map[string]any{
"task_id": publicTaskID,
"base_resp": map[string]any{"status_code": 0, "status_msg": "success"},
})
}
// parseMiniMaxV1UpstreamData 解析 task.Data 中缓存的上游 v1 查询响应(含 FetchTask
// 二跳合并的 download_url)。非 v1 形态(v2 的 {"task":...} 包装或提交响应)返回 nil。
func parseMiniMaxV1UpstreamData(data []byte) map[string]any {
if len(data) == 0 {
return nil
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
return nil
}
if _, hasTask := m["task"]; hasTask {
return nil // v2 形态
}
if _, hasStatus := m["status"]; !hasStatus {
return nil // 提交响应等非查询形态
}
return m
}
// HandleMiniMaxVideoV1Retrieve GET /v1/query/video_generation?task_id= 查询管线。
// 响应为官方 QueryVideoGenerationTaskResp 形态;附加 download_url(FetchTask 二跳解析的
// 限时直链,官方查询响应无此字段,属网关增补)与 error(失败原因)两个增补字段,
// 官方 SDK 忽略未知字段,不影响兼容。
func HandleMiniMaxVideoV1Retrieve(
ctx context.Context,
taskID string,
rc *TaskRelayContext,
dataProvider common.TaskDataProvider,
) {
task, ok := fetchMiniMaxTaskById(ctx, taskID, rc, dataProvider)
if !ok {
return
}
resp := map[string]any{
"task_id": task.PublicTaskID,
"status": minimaxV1StatusFromTask(task.Status),
"base_resp": map[string]any{"status_code": 0, "status_msg": "success"},
}
if task.Status == string(common.TaskStatusFailure) && task.FailReason != "" {
resp["error"] = task.FailReason
}
// 从缓存的上游查询响应富集 file_id/video_width/video_height/download_url
if upstream := parseMiniMaxV1UpstreamData(task.Data); upstream != nil {
for _, key := range []string{"file_id", "video_width", "video_height", "download_url"} {
if v, has := upstream[key]; has {
resp[key] = v
}
}
}
// 成品直链以任务记录的 ResultURL 为准(轮询结算时写入),上游回放仅兜底
if task.ResultURL != "" {
resp["download_url"] = task.ResultURL
}
writeJSON(rc.Writer, http.StatusOK, resp)
}
+356
View File
@@ -0,0 +1,356 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/qianfree/team-api/relay/common"
)
// MiniMax 官方视频协议(POST /v2/video_generation)入站转换层。
// 原生请求体解析/校验后转成任务框架通用请求体,复用任务提交管线
// (selectTaskChannel 渠道调度 + HandleTaskSubmit 计费/提交/落库)。
//
// 转换产物在保留原生字段(content[]/resolution/duration/ratio/aigc_watermark)之外,
// 双写通用词汇(prompt/seconds/images[]/metadata)——参数倍率规则的匹配源是归一化
// 任务体(「规则跟语义走不跟入口协议走」,见 internal/logic/billing/param_multiplier.go),
// 双写保证按通用路径配置的倍率规则对官方协议入站同样生效。
// minimaxVideoProtocol TaskRelayContext.Protocol 的 MiniMax 官方视频协议标识
const minimaxVideoProtocol = "minimax_native"
// minimaxVideoCreateRequest MiniMax 官方创建请求的归一形态(JSON 解析后的产物)
type minimaxVideoCreateRequest struct {
Model string
Prompt string // content[] 中首个非空 text 项(派生,供计费倍率匹配与审计)
Content []any
Resolution string
Duration int
Ratio string
AigcWatermark *bool
}
// minimaxVideoRequestEcho 提交时落 PrivateData.request_echo 的请求回显,
// retrieve 时用于回显 prompt/resolution/duration/ratio(任务未轮询到上游数据前的兜底)
type minimaxVideoRequestEcho struct {
Prompt string `json:"prompt"`
Resolution string `json:"resolution,omitempty"`
Duration int `json:"duration,omitempty"`
Ratio string `json:"ratio,omitempty"`
}
// jsonMiniMaxVideoCreateRequest JSON 编码的官方创建请求结构。
// duration 兼容 number 与 string;content 保留 RawMessage 以校验数组形态。
type jsonMiniMaxVideoCreateRequest struct {
Model string `json:"model"`
Content json.RawMessage `json:"content"`
Resolution string `json:"resolution"`
Duration json.RawMessage `json:"duration"`
Ratio string `json:"ratio"`
AigcWatermark *bool `json:"aigc_watermark"`
}
// ParseMiniMaxVideoCreateRequest 解析官方创建请求。
// 返回的 TaskError 已携带面向客户端的 HTTP 状态码与错误信息。
func ParseMiniMaxVideoCreateRequest(body []byte) (*minimaxVideoCreateRequest, *common.TaskError) {
var req jsonMiniMaxVideoCreateRequest
if err := json.Unmarshal(body, &req); err != nil {
return nil, &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid request body: " + err.Error()}
}
out := &minimaxVideoCreateRequest{
Model: strings.TrimSpace(req.Model),
Resolution: strings.TrimSpace(req.Resolution),
Ratio: strings.TrimSpace(req.Ratio),
AigcWatermark: req.AigcWatermark,
}
// content 必须是数组
if len(req.Content) > 0 && string(req.Content) != "null" {
var content []any
if err := json.Unmarshal(req.Content, &content); err != nil {
return nil, &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid content: expected an array"}
}
out.Content = content
}
// duration 兼容 number(官方 schema)与 string(宽松兼容)
duration, terr := parseMiniMaxDuration(req.Duration)
if terr != nil {
return nil, terr
}
out.Duration = duration
// 派生 prompt:content[] 中首个非空 text 项
for _, item := range out.Content {
m, ok := item.(map[string]any)
if !ok {
continue
}
if t, _ := m["type"].(string); t == "text" {
if text, ok := m["text"].(string); ok && strings.TrimSpace(text) != "" {
out.Prompt = text
break
}
}
}
return out, nil
}
// parseMiniMaxDuration 解析 duration 字段(integer 或数字字符串,须为正数)
func parseMiniMaxDuration(raw json.RawMessage) (int, *common.TaskError) {
if len(raw) == 0 || string(raw) == "null" {
return 0, nil
}
var n json.Number
if err := json.Unmarshal(raw, &n); err == nil {
if d, err := strconv.Atoi(n.String()); err == nil {
return d, nil
}
// 小数秒向下取整(官方档位均为整数,宽松处理)
if f, err := strconv.ParseFloat(n.String(), 64); err == nil && f > 0 {
return int(f), nil
}
}
return 0, &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid duration field: must be a positive integer"}
}
// ValidateMiniMaxVideoCreateRequest 请求必填校验(对齐官方 schema required:
// model / content(含非空 text 项)/ resolution / duration;数值档位差异交给上游裁决)
func ValidateMiniMaxVideoCreateRequest(req *minimaxVideoCreateRequest) *common.TaskError {
if req.Model == "" {
return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "model is required"}
}
if len(req.Content) == 0 {
return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "content is required"}
}
if req.Prompt == "" {
return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "content must contain a non-empty text item"}
}
if req.Resolution == "" {
return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "resolution is required"}
}
if req.Duration <= 0 {
return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "duration is required"}
}
return nil
}
// BuildMiniMaxVideoTaskBody 把官方创建请求转成任务框架通用请求体(JSON 字节)。
// callback_url 不进入通用任务体(网关轮询计费,上游回调与网关 ID 体系不兼容)。
func BuildMiniMaxVideoTaskBody(req *minimaxVideoCreateRequest) ([]byte, error) {
body := map[string]any{
"model": req.Model,
"prompt": req.Prompt,
"content": req.Content,
"resolution": req.Resolution,
"seconds": strconv.Itoa(req.Duration), // 通用词汇双写(kling/volcengine 消费同一键)
"duration": req.Duration,
"metadata": map[string]any{
"resolution": req.Resolution,
"duration": req.Duration,
},
}
if req.Ratio != "" {
body["ratio"] = req.Ratio
body["metadata"].(map[string]any)["ratio"] = req.Ratio
}
if req.AigcWatermark != nil {
body["aigc_watermark"] = *req.AigcWatermark
}
// images[] 双写:content 中首帧/无 role 的 image_url 项提取 URL(图生视频判定与参数倍率用)
if images := extractMiniMaxFrameImages(req.Content); len(images) > 0 {
body["images"] = images
}
data, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal minimax video task body: %w", err)
}
return data, nil
}
// extractMiniMaxFrameImages 从 content[] 提取图片 URL(role=first_frame 或未标 role 的单图)
func extractMiniMaxFrameImages(content []any) []string {
images := make([]string, 0, 2)
for _, item := range content {
m, ok := item.(map[string]any)
if !ok {
continue
}
if t, _ := m["type"].(string); t != "image_url" {
continue
}
role, _ := m["role"].(string)
if role != "" && role != "first_frame" {
continue
}
if media, ok := m["image_url"].(map[string]any); ok {
if url, ok := media["url"].(string); ok && url != "" {
images = append(images, url)
}
}
}
return images
}
// NewMiniMaxRequestEcho 从归一请求构造提交回显(胶水层经 TaskRelayContext.RequestEcho 传入,
// 提交时随 PrivateData 落库,retrieve 时在上游数据未就绪前回显)
func NewMiniMaxRequestEcho(req *minimaxVideoCreateRequest) minimaxVideoRequestEcho {
return minimaxVideoRequestEcho{
Prompt: req.Prompt,
Resolution: req.Resolution,
Duration: req.Duration,
Ratio: req.Ratio,
}
}
// ==================== v1(Hailuo 系列)官方协议入站 ====================
// minimaxVideoV1Protocol TaskRelayContext.Protocol 的 MiniMax v1 官方视频协议标识
const minimaxVideoV1Protocol = "minimax_native_v1"
// minimaxVideoV1CreateRequest v1 官方创建请求的归一形态(扁平字段协议,无 content[])。
// resolution/duration 官方可选(有默认值),零值表示未指定。
type minimaxVideoV1CreateRequest struct {
Model string
Prompt string
FirstFrameImage string
LastFrameImage string
SubjectReference json.RawMessage
Duration int
Resolution string
PromptOptimizer *bool
FastPretreatment *bool
AigcWatermark *bool
}
// minimaxVideoV1RequestEcho v1 提交回显(任务未轮询到上游数据前的兜底)
type minimaxVideoV1RequestEcho struct {
Prompt string `json:"prompt"`
Resolution string `json:"resolution,omitempty"`
Duration int `json:"duration,omitempty"`
}
// ParseMiniMaxVideoV1CreateRequest 解析 v1 官方创建请求(POST /v1/video_generation,扁平字段)。
// 文生/图生/首尾帧/主体参考四种形态共用端点,按字段自然区分,duration 兼容 number 与 string。
func ParseMiniMaxVideoV1CreateRequest(body []byte) (*minimaxVideoV1CreateRequest, *common.TaskError) {
var raw struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
FirstFrameImage string `json:"first_frame_image"`
LastFrameImage string `json:"last_frame_image"`
SubjectReference json.RawMessage `json:"subject_reference"`
Duration json.RawMessage `json:"duration"`
Resolution string `json:"resolution"`
PromptOptimizer *bool `json:"prompt_optimizer"`
FastPretreatment *bool `json:"fast_pretreatment"`
AigcWatermark *bool `json:"aigc_watermark"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid request body: " + err.Error()}
}
duration, terr := parseMiniMaxDuration(raw.Duration)
if terr != nil {
return nil, terr
}
return &minimaxVideoV1CreateRequest{
Model: strings.TrimSpace(raw.Model),
Prompt: raw.Prompt,
FirstFrameImage: strings.TrimSpace(raw.FirstFrameImage),
LastFrameImage: strings.TrimSpace(raw.LastFrameImage),
SubjectReference: raw.SubjectReference,
Duration: duration,
Resolution: strings.TrimSpace(raw.Resolution),
PromptOptimizer: raw.PromptOptimizer,
FastPretreatment: raw.FastPretreatment,
AigcWatermark: raw.AigcWatermark,
}, nil
}
// ValidateMiniMaxVideoV1CreateRequest 请求必填校验(对齐官方四形态约束):
// model 必填;prompt(文生)/ first_frame_image(图生)/ subject_reference(主体参考)至少其一。
func ValidateMiniMaxVideoV1CreateRequest(req *minimaxVideoV1CreateRequest) *common.TaskError {
if req.Model == "" {
return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "model is required"}
}
if strings.TrimSpace(req.Prompt) == "" && req.FirstFrameImage == "" && len(req.SubjectReference) == 0 {
return &common.TaskError{StatusCode: http.StatusBadRequest,
Message: "one of prompt / first_frame_image / subject_reference is required"}
}
return nil
}
// BuildMiniMaxVideoV1TaskBody 把 v1 官方创建请求转成任务框架通用请求体。
// callback_url 不进入任务体(网关轮询计费,上游回调与网关 ID 体系不兼容);
// 同时双写通用词汇(seconds/images[]/metadata),保证按通用路径配置的参数倍率规则对 v1 入站同样生效。
func BuildMiniMaxVideoV1TaskBody(req *minimaxVideoV1CreateRequest) ([]byte, error) {
body := map[string]any{
"model": req.Model,
}
if strings.TrimSpace(req.Prompt) != "" {
body["prompt"] = req.Prompt
}
if req.FirstFrameImage != "" {
body["first_frame_image"] = req.FirstFrameImage
body["images"] = []string{req.FirstFrameImage}
}
if req.LastFrameImage != "" {
body["last_frame_image"] = req.LastFrameImage
}
if len(req.SubjectReference) > 0 && string(req.SubjectReference) != "null" {
body["subject_reference"] = req.SubjectReference
}
if req.Resolution != "" {
body["resolution"] = req.Resolution
}
if req.Duration > 0 {
body["duration"] = req.Duration
body["seconds"] = strconv.Itoa(req.Duration)
}
if req.PromptOptimizer != nil {
body["prompt_optimizer"] = *req.PromptOptimizer
}
if req.FastPretreatment != nil {
body["fast_pretreatment"] = *req.FastPretreatment
}
if req.AigcWatermark != nil {
body["aigc_watermark"] = *req.AigcWatermark
}
// 通用词汇双写(metadata 键)
metadata := map[string]any{}
if req.Resolution != "" {
metadata["resolution"] = req.Resolution
}
if req.Duration > 0 {
metadata["duration"] = req.Duration
}
if req.FirstFrameImage != "" {
metadata["image"] = req.FirstFrameImage
}
if len(metadata) > 0 {
body["metadata"] = metadata
}
data, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal minimax v1 video task body: %w", err)
}
return data, nil
}
// NewMiniMaxV1RequestEcho 从归一请求构造 v1 提交回显
func NewMiniMaxV1RequestEcho(req *minimaxVideoV1CreateRequest) minimaxVideoV1RequestEcho {
return minimaxVideoV1RequestEcho{
Prompt: req.Prompt,
Resolution: req.Resolution,
Duration: req.Duration,
}
}
+147
View File
@@ -0,0 +1,147 @@
package handler
import (
"encoding/json"
"testing"
)
// MiniMax 官方创建请求的解析 / 校验 / 通用任务体转换单测。
func TestParseMiniMaxVideoCreateRequest_Full(t *testing.T) {
body := `{
"model": "MiniMax-H3",
"content": [
{"type": "text", "text": "海浪拍打礁石"},
{"type": "image_url", "image_url": {"url": "https://cdn.example/a.png"}, "role": "first_frame"}
],
"resolution": "2K",
"duration": 8,
"ratio": "16:9",
"aigc_watermark": true,
"callback_url": "https://example.com/cb"
}`
req, taskErr := ParseMiniMaxVideoCreateRequest([]byte(body))
if taskErr != nil {
t.Fatalf("parse: %+v", taskErr)
}
if req.Model != "MiniMax-H3" || req.Prompt != "海浪拍打礁石" || req.Resolution != "2K" || req.Duration != 8 || req.Ratio != "16:9" {
t.Fatalf("parsed fields: %+v", req)
}
if req.AigcWatermark == nil || !*req.AigcWatermark {
t.Fatalf("aigc_watermark: %v", req.AigcWatermark)
}
if len(req.Content) != 2 {
t.Fatalf("content items: %d", len(req.Content))
}
}
func TestParseMiniMaxVideoCreateRequest_DurationString(t *testing.T) {
// duration 宽松兼容字符串数字
req, taskErr := ParseMiniMaxVideoCreateRequest([]byte(`{"model":"MiniMax-H3","content":[{"type":"text","text":"hi"}],"resolution":"768P","duration":"6"}`))
if taskErr != nil || req.Duration != 6 {
t.Fatalf("duration string parse: %+v err=%+v", req, taskErr)
}
// 非法 duration 报 400
if _, taskErr := ParseMiniMaxVideoCreateRequest([]byte(`{"model":"MiniMax-H3","content":[{"type":"text","text":"hi"}],"resolution":"768P","duration":"abc"}`)); taskErr == nil {
t.Fatal("expected error for invalid duration")
}
}
func TestParseMiniMaxVideoCreateRequest_InvalidContent(t *testing.T) {
if _, taskErr := ParseMiniMaxVideoCreateRequest([]byte(`{"model":"MiniMax-H3","content":{"type":"text"},"resolution":"768P","duration":6}`)); taskErr == nil {
t.Fatal("expected error when content is not an array")
}
}
func TestValidateMiniMaxVideoCreateRequest(t *testing.T) {
valid := &minimaxVideoCreateRequest{
Model: "MiniMax-H3", Prompt: "hi", Content: []any{map[string]any{"type": "text", "text": "hi"}},
Resolution: "768P", Duration: 6,
}
if taskErr := ValidateMiniMaxVideoCreateRequest(valid); taskErr != nil {
t.Fatalf("valid request should pass: %+v", taskErr)
}
cases := map[string]*minimaxVideoCreateRequest{
"missing model": {Prompt: "hi", Resolution: "768P", Duration: 6},
"missing content": {Model: "MiniMax-H3", Prompt: "hi", Resolution: "768P", Duration: 6},
"missing text item": {Model: "MiniMax-H3", Resolution: "768P", Duration: 6},
"missing resolution": {Model: "MiniMax-H3", Prompt: "hi", Duration: 6},
"missing duration": {Model: "MiniMax-H3", Prompt: "hi", Resolution: "768P"},
}
for name, req := range cases {
if taskErr := ValidateMiniMaxVideoCreateRequest(req); taskErr == nil {
t.Errorf("%s: expected 400", name)
}
}
}
func TestBuildMiniMaxVideoTaskBody(t *testing.T) {
req := &minimaxVideoCreateRequest{
Model: "MiniMax-H3",
Prompt: "海浪拍打礁石",
Content: []any{
map[string]any{"type": "text", "text": "海浪拍打礁石"},
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://cdn.example/a.png"}, "role": "first_frame"},
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://cdn.example/ref.png"}, "role": "reference_image"},
},
Resolution: "768P",
Duration: 6,
Ratio: "16:9",
}
body, err := BuildMiniMaxVideoTaskBody(req)
if err != nil {
t.Fatalf("build: %v", err)
}
var m map[string]any
if err := json.Unmarshal(body, &m); err != nil {
t.Fatalf("unmarshal: %v", err)
}
// 原生字段保留
if m["model"] != "MiniMax-H3" || m["prompt"] != "海浪拍打礁石" || m["resolution"] != "768P" || m["duration"] != float64(6) || m["ratio"] != "16:9" {
t.Fatalf("native fields: %v", m)
}
if _, has := m["callback_url"]; has {
t.Fatal("callback_url must never enter the task body")
}
// 通用词汇双写
if m["seconds"] != "6" {
t.Fatalf("seconds dual-write: %v", m["seconds"])
}
images, _ := m["images"].([]any)
// 只提取 first_frame / 无 role 的图片,reference_image 不算图生视频输入
if len(images) != 1 || images[0] != "https://cdn.example/a.png" {
t.Fatalf("images dual-write: %v", m["images"])
}
meta, _ := m["metadata"].(map[string]any)
if meta["resolution"] != "768P" || meta["duration"] != float64(6) || meta["ratio"] != "16:9" {
t.Fatalf("metadata dual-write: %v", m["metadata"])
}
// content 原样保留(3 项)
if content, _ := m["content"].([]any); len(content) != 3 {
t.Fatalf("content preserved: %v", m["content"])
}
}
func TestBuildMiniMaxVideoTaskBody_Minimal(t *testing.T) {
// 无 ratio / 无图片:ratio 与 images 键不产出
req := &minimaxVideoCreateRequest{
Model: "MiniMax-H3", Prompt: "hi",
Content: []any{map[string]any{"type": "text", "text": "hi"}},
Resolution: "768P", Duration: 4,
}
body, err := BuildMiniMaxVideoTaskBody(req)
if err != nil {
t.Fatalf("build: %v", err)
}
var m map[string]any
_ = json.Unmarshal(body, &m)
if _, has := m["ratio"]; has {
t.Fatalf("ratio should be absent when not specified: %v", m)
}
if _, has := m["images"]; has {
t.Fatalf("images should be absent without image input: %v", m)
}
}
@@ -0,0 +1,332 @@
package handler
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/qianfree/team-api/relay/common"
"github.com/qianfree/team-api/relay/constant"
)
// 端到端集成测试:MiniMax 官方协议(/v2)与 OpenAI Videos 协议(/v1/videos)→ 转换 →
// 任务提交管线(minimax 适配器 + mock 上游)→ {"task_id"} 响应 + minimax 平台任务落库 +
// 上游收到映射后的 v2 参数。
// newMiniMaxUpstream mock MiniMax v2 上游:记录提交请求体,返回官方提交响应
func newMiniMaxUpstream(t *testing.T) (*httptest.Server, *map[string]any) {
t.Helper()
received := map[string]any{}
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v2/video_generation" {
t.Errorf("unexpected upstream path: %s", r.URL.Path)
}
if auth := r.Header.Get("Authorization"); auth != "Bearer sk-test" {
t.Errorf("unexpected authorization: %q", auth)
}
body, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(body, &received); err != nil {
t.Errorf("upstream body not json: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"task_id": "424010985738629"})
}))
return upstream, &received
}
func miniMaxChannelMeta(baseURL string) *common.ChannelMeta {
return &common.ChannelMeta{
ChannelID: 21,
ChannelType: int(constant.ProviderMiniMax),
ChannelName: "mock-minimax",
BaseURL: baseURL,
ApiKey: "sk-test",
UpstreamModelName: "MiniMax-H3",
}
}
// TestHandleTaskSubmit_MiniMaxNativeProtocol_FullPipeline 官方协议全链路:
// 原生 content[] 透传、callback_url 剥离、{"task_id"} 响应、minimax 平台落库、请求回显持久化。
func TestHandleTaskSubmit_MiniMaxNativeProtocol_FullPipeline(t *testing.T) {
upstream, received := newMiniMaxUpstream(t)
defer upstream.Close()
// 官方请求 → 归一 → 通用任务体(与胶水层 HandleMiniMaxVideoSubmit 相同链路)
raw := `{
"model": "MiniMax-H3",
"content": [
{"type": "text", "text": "海浪拍打礁石,慢镜头。"},
{"type": "image_url", "image_url": {"url": "https://cdn.example/first.png"}, "role": "first_frame"}
],
"resolution": "768P",
"duration": 6,
"ratio": "16:9",
"callback_url": "https://evil.example/cb"
}`
req, taskErr := ParseMiniMaxVideoCreateRequest([]byte(raw))
if taskErr != nil {
t.Fatalf("parse: %+v", taskErr)
}
if taskErr := ValidateMiniMaxVideoCreateRequest(req); taskErr != nil {
t.Fatalf("validate: %+v", taskErr)
}
taskBody, err := BuildMiniMaxVideoTaskBody(req)
if err != nil {
t.Fatalf("build task body: %v", err)
}
rec := httptest.NewRecorder()
rc := &TaskRelayContext{
TenantID: 1,
UserID: 2,
ApiKeyID: 3,
RequestID: "req_mm_native",
Writer: rec,
Protocol: minimaxVideoProtocol,
RelayMode: int(constant.RelayModeMiniMaxVideo),
RequestEcho: NewMiniMaxRequestEcho(req),
}
data := &stubVideosSubmitProvider{}
HandleTaskSubmit(context.Background(), taskBody, "/v2/video_generation", http.Header{}, rc, data, &stubVideosBillingProvider{}, miniMaxChannelMeta(upstream.URL))
// 客户端响应:官方 VideoGenerationV2Resp 形态
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String())
}
resp := decodeVideosJSON(t, rec)
if len(resp) != 1 {
t.Fatalf("submit response should only carry task_id: %s", rec.Body.String())
}
taskID, _ := resp["task_id"].(string)
if !strings.HasPrefix(taskID, "task_") {
t.Fatalf("expected task_ prefixed id, got %q", taskID)
}
if rc.TaskID != taskID {
t.Fatalf("rc.TaskID=%q should match response id %q", rc.TaskID, taskID)
}
// 落库任务:minimax 平台 + 上游任务 ID + 请求回显
if data.created == nil {
t.Fatal("expected task record created")
}
if data.created.Platform != "minimax" {
t.Fatalf("expected platform minimax, got %q", data.created.Platform)
}
var pd struct {
UpstreamTaskID string `json:"upstream_task_id"`
RequestEcho *minimaxVideoRequestEcho `json:"request_echo"`
}
if err := json.Unmarshal(data.created.PrivateData, &pd); err != nil {
t.Fatalf("private data unmarshal: %v", err)
}
if pd.UpstreamTaskID != "424010985738629" {
t.Fatalf("upstream task id: %q", pd.UpstreamTaskID)
}
if pd.RequestEcho == nil || pd.RequestEcho.Resolution != "768P" || pd.RequestEcho.Duration != 6 {
t.Fatalf("request echo persisted: %+v", pd.RequestEcho)
}
// 上游收到:content 原样(2 项)、官方参数、无 callback_url
if (*received)["model"] != "MiniMax-H3" {
t.Fatalf("upstream model: %v", (*received)["model"])
}
if (*received)["resolution"] != "768P" || (*received)["duration"] != float64(6) || (*received)["ratio"] != "16:9" {
t.Fatalf("upstream params: %v", *received)
}
if _, has := (*received)["callback_url"]; has {
t.Fatal("callback_url must be stripped before forwarding upstream")
}
content, _ := (*received)["content"].([]any)
if len(content) != 2 {
t.Fatalf("expected content passthrough with 2 items, got %v", (*received)["content"])
}
img, _ := content[1].(map[string]any)
if img["role"] != "first_frame" {
t.Fatalf("content roles preserved: %v", content[1])
}
}
// TestHandleTaskSubmit_OpenAIVideosToMiniMax OpenAI Videos 协议 → minimax 适配器:
// size 原值透传(调用方直接传 MiniMax 原生档位词汇)、seconds → duration、multipart 链路复用。
func TestHandleTaskSubmit_OpenAIVideosToMiniMax(t *testing.T) {
upstream, received := newMiniMaxUpstream(t)
defer upstream.Close()
// OpenAI multipart 创建请求:size 直传 MiniMax 原生档位 768P + 8 秒
mBody, ct := buildMultipartVideosBody(t, map[string]string{
"model": "MiniMax-H3",
"prompt": "a cat surfing",
"seconds": "8",
"size": "768P",
}, "", "", nil)
req, taskErr := ParseVideosCreateRequest(mBody, ct)
if taskErr != nil {
t.Fatalf("parse: %+v", taskErr)
}
taskBody, err := BuildVideosTaskBody(req)
if err != nil {
t.Fatalf("build task body: %v", err)
}
rec := httptest.NewRecorder()
rc := &TaskRelayContext{
TenantID: 1,
UserID: 2,
ApiKeyID: 3,
RequestID: "req_mm_openai",
Writer: rec,
Protocol: videosProtocolOpenAI,
RelayMode: int(constant.RelayModeVideos),
RequestEcho: NewVideosRequestEcho(req),
}
data := &stubVideosSubmitProvider{}
HandleTaskSubmit(context.Background(), taskBody, "/v1/videos", http.Header{}, rc, data, &stubVideosBillingProvider{}, miniMaxChannelMeta(upstream.URL))
// OpenAI 协议响应保持官方 Video 对象
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String())
}
resp := decodeVideosJSON(t, rec)
if resp["object"] != "video" || resp["status"] != "queued" {
t.Fatalf("openai video object: %s", rec.Body.String())
}
if data.created == nil || data.created.Platform != "minimax" {
t.Fatalf("expected minimax task record, got %+v", data.created)
}
// 上游收到映射后的 v2 参数:size 原值透传、seconds→duration、t2v 默认 ratio
if (*received)["resolution"] != "768P" {
t.Fatalf("upstream resolution passthrough: %v", (*received)["resolution"])
}
if (*received)["duration"] != float64(8) {
t.Fatalf("upstream duration: %v", (*received)["duration"])
}
if (*received)["ratio"] != "16:9" {
t.Fatalf("upstream ratio: %v", (*received)["ratio"])
}
content, _ := (*received)["content"].([]any)
if len(content) != 1 {
t.Fatalf("text-only content expected, got %v", (*received)["content"])
}
text, _ := content[0].(map[string]any)
if text["text"] != "a cat surfing" {
t.Fatalf("text item: %v", content[0])
}
}
// TestHandleTaskSubmit_MiniMaxV1NativeProtocol_FullPipeline v1 官方协议全链路:
// 扁平字段透传、callback_url 剥离、{task_id, base_resp} 响应、minimax 平台落库。
func TestHandleTaskSubmit_MiniMaxV1NativeProtocol_FullPipeline(t *testing.T) {
// mock MiniMax v1 上游:记录提交请求体,返回官方提交响应
received := map[string]any{}
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/video_generation" {
t.Errorf("unexpected upstream path: %s", r.URL.Path)
}
body, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(body, &received); err != nil {
t.Errorf("upstream body not json: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"task_id": "v1-up-777",
"base_resp": map[string]any{"status_code": 0, "status_msg": "success"},
})
}))
defer upstream.Close()
raw := `{
"model": "MiniMax-Hailuo-2.3",
"prompt": "一只猫在月光下的屋顶上行走",
"first_frame_image": "https://cdn.example/f.png",
"duration": 10,
"resolution": "768P",
"callback_url": "https://evil.example/cb"
}`
req, taskErr := ParseMiniMaxVideoV1CreateRequest([]byte(raw))
if taskErr != nil {
t.Fatalf("parse: %+v", taskErr)
}
if taskErr := ValidateMiniMaxVideoV1CreateRequest(req); taskErr != nil {
t.Fatalf("validate: %+v", taskErr)
}
taskBody, err := BuildMiniMaxVideoV1TaskBody(req)
if err != nil {
t.Fatalf("build task body: %v", err)
}
rec := httptest.NewRecorder()
rc := &TaskRelayContext{
TenantID: 1,
UserID: 2,
ApiKeyID: 3,
RequestID: "req_mm_v1_native",
Writer: rec,
Protocol: minimaxVideoV1Protocol,
RelayMode: int(constant.RelayModeMiniMaxVideo),
RequestEcho: NewMiniMaxV1RequestEcho(req),
}
data := &stubVideosSubmitProvider{}
channelMeta := &common.ChannelMeta{
ChannelID: 22,
ChannelType: int(constant.ProviderMiniMax),
ChannelName: "mock-minimax",
BaseURL: upstream.URL,
ApiKey: "sk-test",
UpstreamModelName: "MiniMax-Hailuo-2.3",
}
HandleTaskSubmit(context.Background(), taskBody, "/v1/video_generation", http.Header{}, rc, data, &stubVideosBillingProvider{}, channelMeta)
// 客户端响应:官方 VideoGenerationResp 形态(含 base_resp 成功信封)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String())
}
resp := decodeVideosJSON(t, rec)
taskID, _ := resp["task_id"].(string)
if !strings.HasPrefix(taskID, "task_") {
t.Fatalf("expected task_ prefixed id, got %q", taskID)
}
baseResp, _ := resp["base_resp"].(map[string]any)
if baseResp == nil || baseResp["status_code"] != float64(0) {
t.Fatalf("base_resp success envelope: %s", rec.Body.String())
}
// 落库任务:minimax 平台 + 上游任务 ID + 请求回显
if data.created == nil || data.created.Platform != "minimax" {
t.Fatalf("expected minimax task record, got %+v", data.created)
}
var pd struct {
UpstreamTaskID string `json:"upstream_task_id"`
RequestEcho *minimaxVideoV1RequestEcho `json:"request_echo"`
}
if err := json.Unmarshal(data.created.PrivateData, &pd); err != nil {
t.Fatalf("private data unmarshal: %v", err)
}
if pd.UpstreamTaskID != "v1-up-777" {
t.Fatalf("upstream task id: %q", pd.UpstreamTaskID)
}
if pd.RequestEcho == nil || pd.RequestEcho.Duration != 10 || pd.RequestEcho.Resolution != "768P" {
t.Fatalf("request echo persisted: %+v", pd.RequestEcho)
}
// 上游收到:v1 扁平字段、无 content[]、无 callback_url
if received["model"] != "MiniMax-Hailuo-2.3" || received["prompt"] != "一只猫在月光下的屋顶上行走" {
t.Fatalf("upstream flat fields: %v", received)
}
if received["first_frame_image"] != "https://cdn.example/f.png" || received["duration"] != float64(10) || received["resolution"] != "768P" {
t.Fatalf("upstream params: %v", received)
}
if _, has := received["callback_url"]; has {
t.Fatal("callback_url must be stripped before forwarding upstream")
}
if _, has := received["content"]; has {
t.Fatal("v1 request must not carry content[]")
}
}
+427
View File
@@ -0,0 +1,427 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/qianfree/team-api/relay/common"
)
// MiniMax 官方协议查询/删除处理器单测(stub TaskDataProvider)。
// stubMiniMaxTaskProvider 返回预置任务并回查渠道
type stubMiniMaxTaskProvider struct {
common.TaskDataProvider
task *common.AsyncTask
channel *common.ChannelBasicInfo
}
func (s *stubMiniMaxTaskProvider) GetTaskByPublicIDAndUser(_ context.Context, publicTaskID string, _ int64, _ int64) (*common.AsyncTask, error) {
if s.task != nil && s.task.PublicTaskID == publicTaskID {
return s.task, nil
}
return nil, nil
}
func (s *stubMiniMaxTaskProvider) GetChannelByID(_ context.Context, _ int64) (*common.ChannelBasicInfo, error) {
return s.channel, nil
}
// newMiniMaxTask 构造 minimax 平台任务记录(带上游任务 ID,供取消链路使用)
func newMiniMaxTask(status string) *common.AsyncTask {
now := time.Now()
return &common.AsyncTask{
PublicTaskID: "task_mm_1",
Platform: "minimax",
Status: status,
ModelName: "MiniMax-H3",
TenantID: 1,
UserID: 2,
ChannelID: 21,
PrivateData: json.RawMessage(`{"upstream_task_id":"424010985738629"}`),
CreatedAt: now,
UpdatedAt: now,
}
}
func TestMiniMaxStatusFromTask(t *testing.T) {
cases := map[string]string{
"NOT_START": "queued",
"SUBMITTED": "queued",
"QUEUED": "queued",
"IN_PROGRESS": "running",
"SUCCESS": "succeeded",
"FAILURE": "failed",
"WHATEVER": "queued",
}
for in, want := range cases {
if got := minimaxStatusFromTask(in); got != want {
t.Errorf("minimaxStatusFromTask(%q) = %q, want %q", in, got, want)
}
}
}
func TestHandleMiniMaxVideoRetrieve_EnrichFromUpstreamData(t *testing.T) {
task := newMiniMaxTask("SUCCESS")
task.ResultURL = "https://video.cdn.example/out.mp4"
task.Data = json.RawMessage(`{"task":{"id":"up-1","status":"succeeded","content":{"url":"https://video.cdn.example/out.mp4"},"usage":{"total_tokens":273890,"prompt_tokens":13500,"completion_tokens":260390},"resolution":"2K","duration":5,"ratio":"16:9"}}`)
rec := httptest.NewRecorder()
rc := &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}
HandleMiniMaxVideoRetrieve(context.Background(), "task_mm_1", rc, &stubMiniMaxTaskProvider{task: task})
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String())
}
resp := decodeVideosJSON(t, rec)
taskObj, _ := resp["task"].(map[string]any)
if taskObj == nil {
t.Fatalf("expected {\"task\": {...}} wrapper: %s", rec.Body.String())
}
// id 用网关公开 ID(不透出上游 ID)
if taskObj["id"] != "task_mm_1" || taskObj["status"] != "succeeded" {
t.Fatalf("task identity: %s", rec.Body.String())
}
content, _ := taskObj["content"].(map[string]any)
if content["url"] != "https://video.cdn.example/out.mp4" {
t.Fatalf("content.url: %v", taskObj["content"])
}
usage, _ := taskObj["usage"].(map[string]any)
if usage["total_tokens"] != float64(273890) {
t.Fatalf("usage replay: %v", taskObj["usage"])
}
if taskObj["resolution"] != "2K" || taskObj["duration"] != float64(5) || taskObj["ratio"] != "16:9" {
t.Fatalf("enriched specs: %s", rec.Body.String())
}
}
// TestHandleMiniMaxVideoRetrieve_SubmitShapedDataDegrade 提交后首拍轮询前 Data 是提交响应形态,
// 查询必须静默降级(缺失字段不输出,状态以任务记录为准)。
func TestHandleMiniMaxVideoRetrieve_SubmitShapedDataDegrade(t *testing.T) {
task := newMiniMaxTask("SUBMITTED")
task.Data = json.RawMessage(`{"task_id":"424010985738629"}`)
rec := httptest.NewRecorder()
rc := &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}
HandleMiniMaxVideoRetrieve(context.Background(), "task_mm_1", rc, &stubMiniMaxTaskProvider{task: task})
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String())
}
resp := decodeVideosJSON(t, rec)
taskObj, _ := resp["task"].(map[string]any)
if taskObj["status"] != "queued" {
t.Fatalf("status from record: %s", rec.Body.String())
}
if _, has := taskObj["usage"]; has {
t.Fatalf("usage must be absent for submit-shaped data: %s", rec.Body.String())
}
if _, has := taskObj["resolution"]; has {
t.Fatalf("resolution must be absent for submit-shaped data: %s", rec.Body.String())
}
}
// TestHandleMiniMaxVideoRetrieve_PlatformGate 平台门禁:异构平台任务 ID 打 /v2 端点按 404 处理
func TestHandleMiniMaxVideoRetrieve_PlatformGate(t *testing.T) {
task := newMiniMaxTask("SUCCESS")
task.Platform = "kling"
rec := httptest.NewRecorder()
rc := &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}
HandleMiniMaxVideoRetrieve(context.Background(), "task_mm_1", rc, &stubMiniMaxTaskProvider{task: task})
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404 for non-minimax platform, got %d", rec.Code)
}
}
// TestHandleMiniMaxVideoRetrieve_FailedTask 失败任务回放官方 error 对象
func TestHandleMiniMaxVideoRetrieve_FailedTask(t *testing.T) {
task := newMiniMaxTask("FAILURE")
task.FailReason = "content policy violation"
rec := httptest.NewRecorder()
rc := &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}
HandleMiniMaxVideoRetrieve(context.Background(), "task_mm_1", rc, &stubMiniMaxTaskProvider{task: task})
resp := decodeVideosJSON(t, rec)
taskObj, _ := resp["task"].(map[string]any)
if taskObj["status"] != "failed" {
t.Fatalf("failed status: %s", rec.Body.String())
}
errObj, _ := taskObj["error"].(map[string]any)
if errObj["message"] != "content policy violation" {
t.Fatalf("error object: %s", rec.Body.String())
}
}
// newMiniMaxCancelUpstream mock MiniMax v2 取消端点(DELETE /v2/video_generation/{id}):
// 记录路径与方法,按脚本返回成功 / 上游拒绝 / 竞态删记录三种形态。
func newMiniMaxCancelUpstream(t *testing.T, script string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
t.Errorf("cancel must use DELETE, got %s", r.Method)
}
if r.URL.Path != "/v2/video_generation/424010985738629" {
t.Errorf("unexpected cancel path: %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
switch script {
case "ok":
_ = json.NewEncoder(w).Encode(map[string]any{"task_id": "424010985738629", "action": "cancelled", "status": "cancelled"})
case "running":
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"type": "invalid_request_error", "message": "running task cannot be cancelled"}})
case "deleted":
_ = json.NewEncoder(w).Encode(map[string]any{"task_id": "424010985738629", "action": "deleted", "status": "deleted"})
}
}))
}
func TestHandleMiniMaxVideoCancel(t *testing.T) {
// 终态任务:不可取消、不可删除(不做软删),且不触达上游
upstream := newMiniMaxCancelUpstream(t, "ok")
defer upstream.Close()
provider := &stubMiniMaxTaskProvider{
task: newMiniMaxTask("SUCCESS"),
channel: &common.ChannelBasicInfo{ID: 21, BaseURL: upstream.URL, ApiKey: "sk-test"},
}
rec := httptest.NewRecorder()
HandleMiniMaxVideoCancel(context.Background(), "task_mm_1", &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}, provider)
if rec.Code != http.StatusBadRequest {
t.Fatalf("terminal task must not be cancellable/deletable: code=%d body=%s", rec.Code, rec.Body.String())
}
// 运行中任务:前置拦截,不可取消
provider.task = newMiniMaxTask("IN_PROGRESS")
rec = httptest.NewRecorder()
HandleMiniMaxVideoCancel(context.Background(), "task_mm_1", &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}, provider)
if rec.Code != http.StatusBadRequest {
t.Fatalf("running task must not be cancellable: code=%d body=%s", rec.Code, rec.Body.String())
}
// 排队中任务:上游确认取消 → 官方 cancelled 响应
provider.task = newMiniMaxTask("QUEUED")
rec = httptest.NewRecorder()
HandleMiniMaxVideoCancel(context.Background(), "task_mm_1", &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}, provider)
if rec.Code != http.StatusOK {
t.Fatalf("queued cancel should succeed: code=%d body=%s", rec.Code, rec.Body.String())
}
resp := decodeVideosJSON(t, rec)
if resp["task_id"] != "task_mm_1" || resp["action"] != "cancelled" || resp["status"] != "cancelled" {
t.Fatalf("cancel response shape: %s", rec.Body.String())
}
// 本地状态不由取消端点改写(轮询循环对账收敛)
if provider.task.Status != "QUEUED" {
t.Fatalf("cancel endpoint must not mutate local status directly: %s", provider.task.Status)
}
}
// TestHandleMiniMaxVideoCancel_UpstreamRejected 远程取消失败:上游判定运行中,
// 错误原样透传(本地状态滞后时由上游裁决兜底)。
func TestHandleMiniMaxVideoCancel_UpstreamRejected(t *testing.T) {
upstream := newMiniMaxCancelUpstream(t, "running")
defer upstream.Close()
provider := &stubMiniMaxTaskProvider{
task: newMiniMaxTask("QUEUED"), // 本地状态滞后:本地仍是排队中
channel: &common.ChannelBasicInfo{ID: 21, BaseURL: upstream.URL, ApiKey: "sk-test"},
}
rec := httptest.NewRecorder()
HandleMiniMaxVideoCancel(context.Background(), "task_mm_1", &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}, provider)
if rec.Code != http.StatusBadRequest {
t.Fatalf("upstream rejection must relay: code=%d body=%s", rec.Code, rec.Body.String())
}
resp := decodeVideosJSON(t, rec)
errObj, _ := resp["error"].(map[string]any)
if errObj["message"] != "running task cannot be cancelled" {
t.Fatalf("upstream message must be relayed: %s", rec.Body.String())
}
}
// TestHandleMiniMaxVideoCancel_UpstreamDeletedRace 竞态:本地检查后任务已在上游完成,
// 上游执行的是删记录(action=deleted)而非取消——不能向客户端谎报取消成功。
func TestHandleMiniMaxVideoCancel_UpstreamDeletedRace(t *testing.T) {
upstream := newMiniMaxCancelUpstream(t, "deleted")
defer upstream.Close()
provider := &stubMiniMaxTaskProvider{
task: newMiniMaxTask("QUEUED"),
channel: &common.ChannelBasicInfo{ID: 21, BaseURL: upstream.URL, ApiKey: "sk-test"},
}
rec := httptest.NewRecorder()
HandleMiniMaxVideoCancel(context.Background(), "task_mm_1", &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}, provider)
if rec.Code != http.StatusBadRequest {
t.Fatalf("deleted action must not be reported as cancelled: code=%d body=%s", rec.Code, rec.Body.String())
}
}
// TestHandleMiniMaxVideoCancel_V1Rejected v1(Hailuo 系列)任务:上游无取消端点,直接拒绝
func TestHandleMiniMaxVideoCancel_V1Rejected(t *testing.T) {
upstream := newMiniMaxCancelUpstream(t, "ok") // 上游即使可达也不应被调用
defer upstream.Close()
task := newMiniMaxTask("QUEUED")
task.ModelName = "MiniMax-Hailuo-2.3" // UpstreamModel 为空时回落 ModelName 分派
provider := &stubMiniMaxTaskProvider{
task: task,
channel: &common.ChannelBasicInfo{ID: 21, BaseURL: upstream.URL, ApiKey: "sk-test"},
}
rec := httptest.NewRecorder()
HandleMiniMaxVideoCancel(context.Background(), "task_mm_1", &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}, provider)
if rec.Code != http.StatusBadRequest {
t.Fatalf("v1 cancel must be rejected: code=%d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "v1 API has no cancel endpoint") {
t.Fatalf("reject message: %s", rec.Body.String())
}
}
// ==================== v1(Hailuo 系列)官方协议 ====================
func TestMiniMaxV1StatusFromTask(t *testing.T) {
cases := map[string]string{
"NOT_START": "Preparing",
"SUBMITTED": "Preparing",
"QUEUED": "Queueing",
"IN_PROGRESS": "Processing",
"SUCCESS": "Success",
"FAILURE": "Fail",
}
for in, want := range cases {
if got := minimaxV1StatusFromTask(in); got != want {
t.Errorf("minimaxV1StatusFromTask(%q) = %q, want %q", in, got, want)
}
}
}
func TestParseAndValidateMiniMaxVideoV1CreateRequest(t *testing.T) {
// 图生视频形态(prompt 可选)
req, taskErr := ParseMiniMaxVideoV1CreateRequest([]byte(`{
"model": "MiniMax-Hailuo-2.3",
"first_frame_image": "https://cdn.example/f.png",
"prompt": "让画面动起来",
"duration": "10",
"resolution": "768P",
"prompt_optimizer": false,
"callback_url": "https://example.com/cb"
}`))
if taskErr != nil {
t.Fatalf("parse: %+v", taskErr)
}
if req.Model != "MiniMax-Hailuo-2.3" || req.FirstFrameImage != "https://cdn.example/f.png" || req.Duration != 10 || req.Resolution != "768P" {
t.Fatalf("parsed fields: %+v", req)
}
if req.PromptOptimizer == nil || *req.PromptOptimizer {
t.Fatalf("prompt_optimizer: %v", req.PromptOptimizer)
}
if taskErr := ValidateMiniMaxVideoV1CreateRequest(req); taskErr != nil {
t.Fatalf("i2v form should pass: %+v", taskErr)
}
// 必填校验:model 必填;prompt/first_frame_image/subject_reference 至少其一
noModel, _ := ParseMiniMaxVideoV1CreateRequest([]byte(`{"prompt":"hi"}`))
if taskErr := ValidateMiniMaxVideoV1CreateRequest(noModel); taskErr == nil {
t.Fatal("expected model required")
}
empty, _ := ParseMiniMaxVideoV1CreateRequest([]byte(`{"model":"MiniMax-Hailuo-2.3","resolution":"768P"}`))
if taskErr := ValidateMiniMaxVideoV1CreateRequest(empty); taskErr == nil {
t.Fatal("expected one-of-prompt/image/subject required")
}
}
func TestBuildMiniMaxVideoV1TaskBody(t *testing.T) {
req := &minimaxVideoV1CreateRequest{
Model: "MiniMax-Hailuo-2.3",
Prompt: "海浪",
FirstFrameImage: "https://cdn.example/f.png",
Duration: 6,
Resolution: "768P",
}
body, err := BuildMiniMaxVideoV1TaskBody(req)
if err != nil {
t.Fatalf("build: %v", err)
}
var m map[string]any
if err := json.Unmarshal(body, &m); err != nil {
t.Fatalf("unmarshal: %v", err)
}
// v1 扁平字段保留
if m["model"] != "MiniMax-Hailuo-2.3" || m["prompt"] != "海浪" || m["first_frame_image"] != "https://cdn.example/f.png" {
t.Fatalf("flat fields: %v", m)
}
if m["duration"] != float64(6) || m["resolution"] != "768P" {
t.Fatalf("duration/resolution: %v/%v", m["duration"], m["resolution"])
}
if _, has := m["callback_url"]; has {
t.Fatal("callback_url must never enter the task body")
}
// 通用词汇双写
if m["seconds"] != "6" {
t.Fatalf("seconds dual-write: %v", m["seconds"])
}
images, _ := m["images"].([]any)
if len(images) != 1 || images[0] != "https://cdn.example/f.png" {
t.Fatalf("images dual-write: %v", m["images"])
}
meta, _ := m["metadata"].(map[string]any)
if meta["resolution"] != "768P" || meta["duration"] != float64(6) || meta["image"] != "https://cdn.example/f.png" {
t.Fatalf("metadata dual-write: %v", m["metadata"])
}
}
func TestHandleMiniMaxVideoV1Retrieve(t *testing.T) {
// 成功任务:官方 QueryVideoGenerationTaskResp 形态 + Data 富集 + ResultURL 直链
task := newMiniMaxTask("SUCCESS")
task.ModelName = "MiniMax-Hailuo-2.3"
task.ResultURL = "https://cdn.example/out.mp4"
task.Data = json.RawMessage(`{"task_id":"v1-up-1","status":"Success","file_id":"9001","video_width":1280,"video_height":720,"download_url":"https://cdn.example/out.mp4","base_resp":{"status_code":0}}`)
rec := httptest.NewRecorder()
HandleMiniMaxVideoV1Retrieve(context.Background(), "task_mm_1", &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}, &stubMiniMaxTaskProvider{task: task})
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String())
}
resp := decodeVideosJSON(t, rec)
if resp["task_id"] != "task_mm_1" || resp["status"] != "Success" {
t.Fatalf("official shape: %s", rec.Body.String())
}
if resp["file_id"] != "9001" || resp["video_width"] != float64(1280) || resp["video_height"] != float64(720) {
t.Fatalf("enriched fields: %s", rec.Body.String())
}
if resp["download_url"] != "https://cdn.example/out.mp4" {
t.Fatalf("download_url: %s", rec.Body.String())
}
baseResp, _ := resp["base_resp"].(map[string]any)
if baseResp["status_code"] != float64(0) {
t.Fatalf("base_resp success envelope: %s", rec.Body.String())
}
// 失败任务:状态 Fail + 增补 error 字段;v2 形态 Data 不富集
failTask := newMiniMaxTask("FAILURE")
failTask.ModelName = "MiniMax-Hailuo-2.3"
failTask.FailReason = "content violates policy"
failTask.Data = json.RawMessage(`{"task":{"id":"up-1","status":"failed"}}`) // v2 形态:跳过富集
rec = httptest.NewRecorder()
HandleMiniMaxVideoV1Retrieve(context.Background(), "task_mm_1", &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}, &stubMiniMaxTaskProvider{task: failTask})
resp = decodeVideosJSON(t, rec)
if resp["status"] != "Fail" || resp["error"] != "content violates policy" {
t.Fatalf("failed replay: %s", rec.Body.String())
}
if _, has := resp["file_id"]; has {
t.Fatalf("v2-shaped data must not enrich v1 response: %s", rec.Body.String())
}
}
+11 -1
View File
@@ -22,6 +22,7 @@ import (
_ "github.com/qianfree/team-api/relay/taskchannel/gemini"
_ "github.com/qianfree/team-api/relay/taskchannel/kling"
"github.com/qianfree/team-api/relay/taskchannel/midjourney"
_ "github.com/qianfree/team-api/relay/taskchannel/minimax"
_ "github.com/qianfree/team-api/relay/taskchannel/sora"
_ "github.com/qianfree/team-api/relay/taskchannel/suno"
_ "github.com/qianfree/team-api/relay/taskchannel/volcengine"
@@ -329,11 +330,20 @@ func HandleTaskSubmit(
// 设置 TaskID 供外层审计使用
rc.TaskID = publicTaskID
// 11. 返回响应(OpenAI Videos 协议返回官方 Video 对象,legacy 保持原格式)
// 11. 返回响应(OpenAI Videos 协议返回官方 Video 对象;MiniMax 官方协议返回 {"task_id"}
//(v1 含 base_resp 信封);legacy 保持原格式)
if rc.Protocol == videosProtocolOpenAI {
writeVideosSubmitResponse(rc.Writer, publicTaskID, modelName, now, rc.RequestEcho)
return
}
if rc.Protocol == minimaxVideoProtocol {
writeMiniMaxSubmitResponse(rc.Writer, publicTaskID)
return
}
if rc.Protocol == minimaxVideoV1Protocol {
writeMiniMaxV1SubmitResponse(rc.Writer, publicTaskID)
return
}
respBody := map[string]any{
"id": publicTaskID,
"status": "SUBMITTED",
+3 -24
View File
@@ -211,25 +211,10 @@ func parseVideosInputReferenceJSON(raw json.RawMessage) (string, *common.TaskErr
}
}
// videosSizeMapping 官方四档分辨率 → 第三方通用参数(aspect_ratio + resolution)。
// 未命中四档的 size 不做映射(第三方分辨率自由,透传 metadata.size 原值即可)。
var videosSizeMapping = map[string]struct {
AspectRatio string
Resolution string
}{
"720x1280": {"9:16", "720p"},
"1280x720": {"16:9", "720p"},
"1024x1792": {"9:16", "1080p"},
"1792x1024": {"16:9", "1080p"},
}
// BuildVideosTaskBody 把归一后的创建请求转成任务框架通用请求体(JSON 字节)。
// 顶层保留 seconds + images,metadata 写入 duration/ratio/aspect_ratio/resolution/size/image,
// 未识别字段一律透传原值,不在此处做模型能力裁剪。
// 参数采用「双写」策略覆盖两套适配器词汇(未知键互不干扰,json.Unmarshal 自动忽略):
// - kling:顶层 seconds、metadata.aspect_ratio、metadata.image
// - volcengine(seedance):顶层 seconds、images[] 数组(→content[].image_url 首帧)、
// metadata.ratio、metadata.duration、metadata.resolution
// 顶层保留 seconds + images,metadata 写入 duration/size/image。
// size 只借 OpenAI 协议的字段形态、不做任何档位换算:调用方直接传目标供应商的
// 原生分辨率词汇(如 MiniMax 的 768P/2K),原值经 metadata.size 透传给适配器。
func BuildVideosTaskBody(req *videosCreateRequest) ([]byte, error) {
body := map[string]any{
"model": req.Model,
@@ -245,12 +230,6 @@ func BuildVideosTaskBody(req *videosCreateRequest) ([]byte, error) {
}
if req.Size != "" {
metadata["size"] = req.Size
if m, ok := videosSizeMapping[req.Size]; ok {
// aspect_ratio(kling)与 ratio(volcengine)值域同构:"16:9"/"9:16"
metadata["aspect_ratio"] = m.AspectRatio
metadata["ratio"] = m.AspectRatio
metadata["resolution"] = m.Resolution
}
}
if req.InputReference != "" {
metadata["image"] = req.InputReference
+14 -10
View File
@@ -120,12 +120,14 @@ func TestParseVideosCreateRequest_MultipartStringRef(t *testing.T) {
}
}
func TestBuildVideosTaskBody_FullMapping(t *testing.T) {
// TestBuildVideosTaskBody_SizePassthrough size 只借协议字段形态、不做档位换算:
// 原值进 metadata.size,不派生 aspect_ratio/ratio/resolution(值词汇由调用方按目标供应商原生口径提供)。
func TestBuildVideosTaskBody_SizePassthrough(t *testing.T) {
req := &videosCreateRequest{
Model: "kling-v2-master",
Prompt: "a cat",
Seconds: "8",
Size: "1280x720",
Size: "768P",
InputReference: "https://example.com/ref.png",
}
body, err := BuildVideosTaskBody(req)
@@ -151,16 +153,15 @@ func TestBuildVideosTaskBody_FullMapping(t *testing.T) {
if meta["duration"] != float64(8) {
t.Fatalf("expected metadata.duration 8, got %v", meta["duration"])
}
if meta["aspect_ratio"] != "16:9" || meta["resolution"] != "720p" {
t.Fatalf("expected 16:9/720p, got %v/%v", meta["aspect_ratio"], meta["resolution"])
}
// 双写:ratio 供 volcengine(seedance) 消费,images 数组供其图生视频读取
if meta["ratio"] != "16:9" {
t.Fatalf("expected ratio 16:9 for volcengine, got %v", meta["ratio"])
}
if meta["size"] != "1280x720" {
if meta["size"] != "768P" {
t.Fatalf("expected raw size passthrough, got %v", meta["size"])
}
// 不做换算:不从 size 派生任何其他参数键
for _, key := range []string{"aspect_ratio", "ratio", "resolution"} {
if _, has := meta[key]; has {
t.Fatalf("size must not derive %s: %s", key, body)
}
}
if meta["image"] != "https://example.com/ref.png" {
t.Fatalf("expected metadata.image, got %v", meta["image"])
}
@@ -203,6 +204,9 @@ func TestBuildVideosTaskBody_UnknownSizeNoRatio(t *testing.T) {
if _, ok := meta["ratio"]; ok {
t.Fatal("unknown size must not map ratio")
}
if _, ok := meta["resolution"]; ok {
t.Fatal("unknown size must not map resolution")
}
if meta["size"] != "1920x1080" {
t.Fatalf("expected raw size passthrough, got %v", meta["size"])
}
+7 -9
View File
@@ -173,16 +173,13 @@ func TestHandleTaskSubmit_OpenAIVideosProtocol_FullPipeline(t *testing.T) {
t.Fatalf("expected request echo persisted: %+v", pd.RequestEcho)
}
// 6. 断言上游收到映射后的 kling 参数(seconds→duration、size→aspect_ratio)
// 6. 断言上游收到映射后的 kling 参数(seconds→duration;size 不换算,kling 不消费该键)
if upstreamReceived["model_name"] != "kling-v2-master" {
t.Fatalf("upstream model_name: %v", upstreamReceived["model_name"])
}
if upstreamReceived["duration"] != "8" {
t.Fatalf("upstream duration: %v", upstreamReceived["duration"])
}
if upstreamReceived["aspect_ratio"] != "16:9" {
t.Fatalf("upstream aspect_ratio: %v", upstreamReceived["aspect_ratio"])
}
if upstreamReceived["prompt"] != "a cat surfing" {
t.Fatalf("upstream prompt: %v", upstreamReceived["prompt"])
}
@@ -259,18 +256,19 @@ func TestHandleTaskSubmit_OpenAIVideosProtocol_Seedance(t *testing.T) {
t.Fatalf("expected volcengine task record, got %+v", data.created)
}
// 上游收到的 seedance 参数:content 文本+首帧图、duration/ratio/resolution 全部对上
// 上游收到的 seedance 参数:content 文本+首帧图、duration 对上;
// size 不再换算派生(resolution/ratio 由 volcengine 上游默认值兜底)
if upstreamReceived["model"] != "doubao-seedance-2-0-260128" {
t.Fatalf("upstream model: %v", upstreamReceived["model"])
}
if upstreamReceived["duration"] != float64(12) {
t.Fatalf("upstream duration: %v (%T)", upstreamReceived["duration"], upstreamReceived["duration"])
}
if upstreamReceived["ratio"] != "16:9" {
t.Fatalf("upstream ratio: %v", upstreamReceived["ratio"])
if _, has := upstreamReceived["resolution"]; has {
t.Fatalf("size must not derive resolution for volcengine: %v", upstreamReceived["resolution"])
}
if upstreamReceived["resolution"] != "1080p" {
t.Fatalf("upstream resolution: %v", upstreamReceived["resolution"])
if _, has := upstreamReceived["ratio"]; has {
t.Fatalf("size must not derive ratio for volcengine: %v", upstreamReceived["ratio"])
}
content, ok := upstreamReceived["content"].([]any)
if !ok || len(content) != 2 {