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

+54 -1
View File
@@ -69,7 +69,7 @@
| 10003 | `CodeChannelUnavailable` | 没有可用的渠道 |
| ... | 更多见 `consts.go` | ... |
## 大模型代理接口格式(`/v1/*`、`/v1beta/*`、`/suno/*`)
## 大模型代理接口格式(`/v1/*`、`/v1beta/*`、`/v2/*`、`/suno/*`)
### 已注册的代理端点
@@ -92,12 +92,65 @@
| GET | `/v1/realtime` | 实时对话(WebSocket) | OpenAI | WebSocket |
| POST | `/v1/video/generations` | 视频生成(异步任务) | 自定义 | 否 |
| GET | `/v1/video/generations/{task_id}` | 视频生成任务查询 | — | 否 |
| POST | `/v1/videos` | OpenAI Videos 视频生成(multipart/JSON) | OpenAI | 否 |
| GET | `/v1/videos/{video_id}` | OpenAI Videos 任务查询 | — | 否 |
| GET | `/v1/videos/{video_id}/content` | OpenAI Videos 成品下载 | — | 否 |
| DELETE | `/v1/videos/{video_id}` | OpenAI Videos 任务删除(终态软删) | — | 否 |
| GET | `/v1beta/models` | Gemini 模型列表 | Gemini | 否 |
| GET | `/v1beta/models/{model}` | Gemini 模型详情 | Gemini | 否 |
| POST | `/v1beta/models/{model}` | Gemini 内容生成 | Gemini | SSE |
| POST | `/suno/submit/{action}` | Suno 音乐生成提交 | 自定义 | 否 |
| POST | `/suno/fetch` | Suno 批量查询 | 自定义 | 否 |
| GET | `/suno/fetch/{task_id}` | Suno 任务查询 | — | 否 |
| POST | `/v2/video_generation` | MiniMax H3 视频生成(官方协议 v2) | MiniMax | 否 |
| GET | `/v2/query/video_generation/{task_id}` | MiniMax H3 任务查询(v2) | — | 否 |
| DELETE | `/v2/video_generation/{task_id}` | MiniMax H3 任务取消(仅排队中,v2) | — | 否 |
| POST | `/v1/video_generation` | MiniMax Hailuo 视频生成(官方协议 v1,四形态共用) | MiniMax | 否 |
| GET | `/v1/query/video_generation?task_id=` | MiniMax Hailuo 任务查询(v1,query 参数) | — | 否 |
### MiniMax 官方视频协议(`/v2/*`,MiniMax-H3/H3-Max)
对 MiniMax v2 视频协议的原生接入:官方 SDK 把 base_url 指向网关即可直连。协议规格见 `docs/modeldocs/minimax/video/`。
**提交** `POST /v2/video_generation`(Bearer 平台 API Key):
```json
{
"model": "MiniMax-H3",
"content": [{ "type": "text", "text": "海浪拍打礁石,慢镜头。" }],
"resolution": "768P",
"duration": 6,
"ratio": "16:9"
}
```
- `model` / `content`(含非空 text 项)/ `resolution` / `duration` 四项必填;`ratio` 文生视频时必填(不能为 `adaptive`)
- `callback_url` **会被剥离**:网关按自身任务体系轮询计费,上游回调携带的上游 task_id 与网关公开 ID 不互通
- 提交响应为官方形态:`{"task_id": "task_xxx"}`(网关公开任务 ID,非上游 ID)
**查询** `GET /v2/query/video_generation/{task_id}` → `{"task": {官方 VideoTask}}`:
状态机 `queued/running/succeeded/failed`(上游取消的任务回放为 `failed`)。`content.url` 为上游限时直链;
`usage/resolution/duration/ratio` 从最近一次上游查询响应回放,任务提交后首拍轮询前(≤15s)可能缺失。
**取消** `DELETE /v2/video_generation/{task_id}`:仅支持取消**排队中**(queued)的任务,取消结果以远程返回为准——
网关调用上游 DELETE,上游确认 `action=cancelled` 才向客户端返回成功
(`{"task_id": "...", "action": "cancelled", "status": "cancelled"}`);上游拒绝时(如任务已进入 running)
原样透传上游错误。**不支持删除任务记录**(终态任务不可删,软删也不做,任务数据保留用于计费与审计;错误码 `task_not_cancellable`)。
取消确认后本地状态由轮询循环在下一拍(≤15s)对账收敛为 failed,预扣随之退款。
错误响应为 OpenAI 风格 OaiError(`{"error": {"type": "...", "message": "..."}}`),与官方 v2 一致。
**OpenAI 协议互通**:同一渠道下 `POST /v1/videos`(OpenAI Videos 协议)与 `POST /v1/video/generations`(通用任务体)可直接调用 MiniMax 视频模型(H3 与 Hailuo 系列按模型自动分派 v2/v1 上游协议)。`size` 只借 OpenAI 协议的字段形态、**值原样透传不换算**——请直接传目标供应商的原生分辨率词汇(H3:`768P`/`2K`/`480P`;Hailuo 2.x:`768P`/`1080P`);非官方档位值会原样送达上游(上游 400 则任务提交失败、预扣全额退款)。时长取 `seconds`/`duration`/`metadata.duration`(缺省 6 秒),`input_reference`/`images[0]` 作为首帧图(v1 转为 `first_frame_image`),纯文生视频时 v2 ratio 默认 `16:9`。
### MiniMax 官方视频协议 v1(`/v1/video_generation`,Hailuo 系列)
Hailuo 2.x 及旧型号(T2V/I2V/S2V-01,传入即转发不拦截)走 v1 扁平字段协议:
- **提交** `POST /v1/video_generation`:文生(`prompt` 必填)/ 图生(`first_frame_image` 必填,支持 URL 或 data URI)/ 首尾帧(`last_frame_image`,仅 Hailuo-02)/ 主体参考(`subject_reference`,S2V-01)四形态共用;`duration`(默认 6,768P 支持 10)/`resolution`(Hailuo 2.x 默认 `768P`,旧型号默认 `720P`)可选;`prompt_optimizer`/`fast_pretreatment`/`aigc_watermark` 透传;`callback_url` 剥离。响应为官方 `{task_id, base_resp}` 形态(task_id 为网关公开 ID)
- **查询** `GET /v1/query/video_generation?task_id=`:官方 `{task_id, status, file_id, video_width, video_height, base_resp}` 形态,状态机 `Preparing/Queueing/Processing/Success/Fail`;**增补字段** `download_url`(网关轮询时经官方 files/retrieve 二跳解析的限时直链,官方查询响应无此字段)与 `error`(失败原因)
- **成品二跳由网关代取**:官方流程「file_id → GET /v1/files/retrieve → download_url」在轮询时链式完成,`download_url` 落任务记录并回放到查询响应;不暴露 files/retrieve 代理端点
- **取消/删除**:v1 上游无此能力,端点不提供(v2 的 DELETE 端点对 v1 任务返回 400)
- v1 无 usage 返回,计费按请求规格(per_second 矩阵 × 时长),与 v2 口径一致
### OpenAI 格式端点响应
+16
View File
@@ -579,6 +579,22 @@ func registerRelayRoutes(server *ghttp.Server) {
group.POST("/fetch", relay.HandleSunoFetchBatch)
group.GET("/fetch/{task_id}", relay.HandleTaskFetch)
})
// MiniMax 官方视频协议端点(H3 v2,官方 SDK 换 base_url 直连;协议文档 docs/modeldocs/minimax/video/)
server.Group("/v2", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.ContentFilter)
group.POST("/video_generation", relay.HandleMiniMaxVideoSubmit)
group.GET("/query/video_generation/{task_id}", relay.HandleMiniMaxVideoRetrieve)
group.DELETE("/video_generation/{task_id}", relay.HandleMiniMaxVideoCancel)
})
// MiniMax 官方视频协议端点(Hailuo 系列 v1,扁平字段协议;查询走 query 参数,
// 成品经 files/retrieve 二跳由网关轮询时代取,官方无取消/删除端点)
server.Group("/v1", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.ContentFilter)
group.POST("/video_generation", relay.HandleMiniMaxVideoV1Submit)
group.GET("/query/video_generation", relay.HandleMiniMaxVideoV1Retrieve)
})
}
// registerLandingPage 在未内嵌前端(非 embedweb 构建)时,为后端根路径注册项目介绍页,
+224
View File
@@ -0,0 +1,224 @@
package relay
import (
"github.com/gogf/gf/v2/net/ghttp"
"github.com/qianfree/team-api/internal/logic/monitor"
"github.com/qianfree/team-api/internal/middleware"
relay_constant "github.com/qianfree/team-api/relay/constant"
relay_handler "github.com/qianfree/team-api/relay/handler"
)
// MiniMax 官方视频协议入站胶水(/v2 端点):
// 创建请求在此完成官方请求体解析与通用任务体转换,复用任务提交管线
// (selectTaskChannel 渠道调度 + relay_handler.HandleTaskSubmit 计费/提交/落库);
// 查询/删除直连 relay_handler 的 MiniMax 视频处理器。
// 协议文档:docs/modeldocs/minimax/video/
// HandleMiniMaxVideoSubmit 处理 POST /v2/video_generation(创建视频生成任务)
func HandleMiniMaxVideoSubmit(r *ghttp.Request) {
body := r.GetBody()
if len(body) == 0 {
writeVideosError(r, 400, "request body is empty", "")
return
}
// 官方请求体 → 归一结构(content[] 原样保留,派生 prompt)
req, taskErr := relay_handler.ParseMiniMaxVideoCreateRequest(body)
if taskErr != nil {
writeVideosError(r, taskErr.StatusCode, taskErr.Message, taskErr.ErrCode)
return
}
if taskErr := relay_handler.ValidateMiniMaxVideoCreateRequest(req); taskErr != nil {
writeVideosError(r, taskErr.StatusCode, taskErr.Message, taskErr.ErrCode)
return
}
// 转成任务框架通用请求体(适配器消费 content/resolution/duration + 双写通用词汇)
taskBody, err := relay_handler.BuildMiniMaxVideoTaskBody(req)
if err != nil {
writeVideosError(r, 500, "build task body failed", "")
return
}
rc := &relay_handler.TaskRelayContext{
TenantID: middleware.GetTenantID(r.Context()),
UserID: middleware.GetUserID(r.Context()),
ApiKeyID: middleware.GetApiKeyID(r.Context()),
ProjectID: middleware.GetProjectID(r.Context()),
RequestID: r.GetCtxVar("RequestId").String(),
Writer: r.Response.Writer,
Scope: r.GetCtxVar("ApiKeyScope").String(),
ClientIP: r.GetClientIp(),
KeyRateLimitQps: r.GetCtxVar(middleware.CtxKeyApiKeyRateLimitQps).Int(),
KeyConcurrency: r.GetCtxVar(middleware.CtxKeyApiKeyRateLimitConcurrency).Int(),
KeyIpWhitelist: r.GetCtxVar(middleware.CtxKeyApiKeyIpWhitelist).String(),
KeyTotalQuota: r.GetCtxVar(middleware.CtxKeyApiKeyTotalQuota).Float64(),
KeyUsedQuota: r.GetCtxVar(middleware.CtxKeyApiKeyUsedQuota).Float64(),
// MiniMax 官方协议:{"task_id"} 响应 + task_ 前缀 ID + 请求回显落库
// (协议标识与 relay/handler.minimaxVideoProtocol 对齐,对齐 videos.go 胶水层字面量惯例)
Protocol: "minimax_native",
RelayMode: int(relay_constant.RelayModeMiniMaxVideo),
RequestEcho: relay_handler.NewMiniMaxRequestEcho(req),
}
// 选择渠道(模型权限校验 + 调度引擎,与 /v1/video/generations 同一链路)
channelMeta, err := selectTaskChannel(r, taskBody)
if err != nil {
writeTaskChannelSelectionError(r, err)
return
}
// 响应捕获用于审计(对齐 HandleTaskSubmit 模式)
capture := NewResponseCaptureWriter(r.Response.Writer)
rc.Writer = capture
registerAsyncTask(rc.RequestID, rc.TenantID, rc.UserID, rc.ProjectID, req.Model, channelMeta, r.URL.Path)
relay_handler.HandleTaskSubmit(
r.Context(), taskBody, r.URL.Path, r.Header,
rc, taskDataProvider, taskBillingProvider, channelMeta,
)
// 提交成功后切换为任务 ID 跟踪(任务生命周期远超 HTTP 请求)
if rc.TaskID != "" {
monitor.SwitchToTaskID(rc.RequestID, rc.TaskID)
} else {
monitor.UnregisterRequest(rc.RequestID)
}
rc.ForwardingTrace = buildTaskForwardingTrace(r.URL.Path, taskBody, channelMeta, capture.StatusCode())
go recordTaskSubmitAudit(r, rc, capture, taskBody)
}
// HandleMiniMaxVideoRetrieve 处理 GET /v2/query/video_generation/{task_id}(查询视频任务)
func HandleMiniMaxVideoRetrieve(r *ghttp.Request) {
taskID := r.Get("task_id").String()
if taskID == "" {
writeVideosError(r, 400, "task id is required", "")
return
}
rc := &relay_handler.TaskRelayContext{
TenantID: middleware.GetTenantID(r.Context()),
UserID: middleware.GetUserID(r.Context()),
ApiKeyID: middleware.GetApiKeyID(r.Context()),
ProjectID: middleware.GetProjectID(r.Context()),
RequestID: r.GetCtxVar("RequestId").String(),
Writer: r.Response.Writer,
}
relay_handler.HandleMiniMaxVideoRetrieve(r.Context(), taskID, rc, taskDataProvider)
}
// HandleMiniMaxVideoCancel 处理 DELETE /v2/video_generation/{task_id}(取消排队中的视频任务)
func HandleMiniMaxVideoCancel(r *ghttp.Request) {
taskID := r.Get("task_id").String()
if taskID == "" {
writeVideosError(r, 400, "task id is required", "")
return
}
rc := &relay_handler.TaskRelayContext{
TenantID: middleware.GetTenantID(r.Context()),
UserID: middleware.GetUserID(r.Context()),
ApiKeyID: middleware.GetApiKeyID(r.Context()),
ProjectID: middleware.GetProjectID(r.Context()),
RequestID: r.GetCtxVar("RequestId").String(),
Writer: r.Response.Writer,
}
relay_handler.HandleMiniMaxVideoCancel(r.Context(), taskID, rc, taskDataProvider)
}
// HandleMiniMaxVideoV1Submit 处理 POST /v1/video_generation(MiniMax v1 官方协议创建,
// 文生/图生/首尾帧/主体参考四形态共用端点,扁平字段)
func HandleMiniMaxVideoV1Submit(r *ghttp.Request) {
body := r.GetBody()
if len(body) == 0 {
writeVideosError(r, 400, "request body is empty", "")
return
}
req, taskErr := relay_handler.ParseMiniMaxVideoV1CreateRequest(body)
if taskErr != nil {
writeVideosError(r, taskErr.StatusCode, taskErr.Message, taskErr.ErrCode)
return
}
if taskErr := relay_handler.ValidateMiniMaxVideoV1CreateRequest(req); taskErr != nil {
writeVideosError(r, taskErr.StatusCode, taskErr.Message, taskErr.ErrCode)
return
}
taskBody, err := relay_handler.BuildMiniMaxVideoV1TaskBody(req)
if err != nil {
writeVideosError(r, 500, "build task body failed", "")
return
}
rc := &relay_handler.TaskRelayContext{
TenantID: middleware.GetTenantID(r.Context()),
UserID: middleware.GetUserID(r.Context()),
ApiKeyID: middleware.GetApiKeyID(r.Context()),
ProjectID: middleware.GetProjectID(r.Context()),
RequestID: r.GetCtxVar("RequestId").String(),
Writer: r.Response.Writer,
Scope: r.GetCtxVar("ApiKeyScope").String(),
ClientIP: r.GetClientIp(),
KeyRateLimitQps: r.GetCtxVar(middleware.CtxKeyApiKeyRateLimitQps).Int(),
KeyConcurrency: r.GetCtxVar(middleware.CtxKeyApiKeyRateLimitConcurrency).Int(),
KeyIpWhitelist: r.GetCtxVar(middleware.CtxKeyApiKeyIpWhitelist).String(),
KeyTotalQuota: r.GetCtxVar(middleware.CtxKeyApiKeyTotalQuota).Float64(),
KeyUsedQuota: r.GetCtxVar(middleware.CtxKeyApiKeyUsedQuota).Float64(),
// MiniMax v1 官方协议:{task_id, base_resp} 响应 + task_ 前缀 ID + 请求回显落库
Protocol: "minimax_native_v1",
RelayMode: int(relay_constant.RelayModeMiniMaxVideo),
RequestEcho: relay_handler.NewMiniMaxV1RequestEcho(req),
}
// 选择渠道(模型权限校验 + 调度引擎,与 /v1/video/generations 同一链路)
channelMeta, err := selectTaskChannel(r, taskBody)
if err != nil {
writeTaskChannelSelectionError(r, err)
return
}
capture := NewResponseCaptureWriter(r.Response.Writer)
rc.Writer = capture
registerAsyncTask(rc.RequestID, rc.TenantID, rc.UserID, rc.ProjectID, req.Model, channelMeta, r.URL.Path)
relay_handler.HandleTaskSubmit(
r.Context(), taskBody, r.URL.Path, r.Header,
rc, taskDataProvider, taskBillingProvider, channelMeta,
)
if rc.TaskID != "" {
monitor.SwitchToTaskID(rc.RequestID, rc.TaskID)
} else {
monitor.UnregisterRequest(rc.RequestID)
}
rc.ForwardingTrace = buildTaskForwardingTrace(r.URL.Path, taskBody, channelMeta, capture.StatusCode())
go recordTaskSubmitAudit(r, rc, capture, taskBody)
}
// HandleMiniMaxVideoV1Retrieve 处理 GET /v1/query/video_generation?task_id=(v1 官方协议任务查询)
func HandleMiniMaxVideoV1Retrieve(r *ghttp.Request) {
taskID := r.Get("task_id").String()
if taskID == "" {
writeVideosError(r, 400, "task id is required", "")
return
}
rc := &relay_handler.TaskRelayContext{
TenantID: middleware.GetTenantID(r.Context()),
UserID: middleware.GetUserID(r.Context()),
ApiKeyID: middleware.GetApiKeyID(r.Context()),
ProjectID: middleware.GetProjectID(r.Context()),
RequestID: r.GetCtxVar("RequestId").String(),
Writer: r.Response.Writer,
}
relay_handler.HandleMiniMaxVideoV1Retrieve(r.Context(), taskID, rc, taskDataProvider)
}
+7 -1
View File
@@ -344,10 +344,16 @@ func pollSingleTask(ctx context.Context, adaptor common.TaskAdaptor, channel *co
return
}
// 查询上游状态
// 查询上游状态(model 供供应商适配器按模型分派协议端点,如 MiniMax v1/v2;
// 其余适配器按结构体解析 taskData,自动忽略未知键)
upstreamModel := task.UpstreamModel
if upstreamModel == "" {
upstreamModel = task.ModelName
}
taskData, _ := json.Marshal(map[string]any{
"task_id": pd.UpstreamTaskID,
"use_proxy": parseChannelUseProxy(channel.Settings),
"model": upstreamModel,
})
resp, err := adaptor.FetchTask(channel.BaseURL, channel.ApiKey, taskData)
+3
View File
@@ -226,6 +226,9 @@ func (s *sTenant) ExportTasks(ctx context.Context, req *v1.TenantTaskExportReq)
"kling": "Kling",
"midjourney": "Midjourney",
"suno": "Suno",
"volcengine": "火山引擎",
"ali": "阿里",
"minimax": "MiniMax",
}
// 预扣/实际费用属 bil_ 记账层,币种 = 系统本位币
+2 -2
View File
@@ -127,8 +127,8 @@ func TestConvertRequest_ThinkingBudgetStripping(t *testing.T) {
// 不影响既有的 top_p 裁剪行为。
func TestConvertRequest_TopPCappingRegression(t *testing.T) {
body := map[string]json.RawMessage{
"model": json.RawMessage(`"qwen-plus"`),
"top_p": json.RawMessage(`1.5`),
"model": json.RawMessage(`"qwen-plus"`),
"top_p": json.RawMessage(`1.5`),
"thinking_budget": json.RawMessage(`128`),
}
requestBody, _ := json.Marshal(body)
+4 -4
View File
@@ -15,10 +15,10 @@ func TestOpenaiAdaptorStreamOptionsInjection(t *testing.T) {
body := []byte(`{"model":"doubao-pro","messages":[{"role":"user","content":"hi"}],"stream":true}`)
tests := []struct {
name string
mode constant.RelayMode
isStream bool
wantInj bool
name string
mode constant.RelayMode
isStream bool
wantInj bool
}{
{"流式 chat 注入 stream_options", constant.RelayModeChatCompletions, true, true},
{"非流式 chat 不注入", constant.RelayModeChatCompletions, false, false},
+3
View File
@@ -32,6 +32,7 @@ const (
RelayModeMjImage // GET /mj/image/:id
RelayModeResponsesCompact // POST /v1/responses/compact
RelayModeVideos // OpenAI Videos 协议 /v1/videos(创建/查询/删除/内容下载)
RelayModeMiniMaxVideo // MiniMax 官方协议 /v2/video_generation(H3 v2 提交/查询/删除)
)
// Path2RelayMode 根据请求路径判断 RelayMode
@@ -156,6 +157,8 @@ func (m RelayMode) String() string {
return "images_edits"
case RelayModeVideos:
return "videos"
case RelayModeMiniMaxVideo:
return "minimax_video"
case RelayModeMjSubmit:
return "mj_submit"
case RelayModeMjFetch:
+3
View File
@@ -13,6 +13,7 @@ const (
TaskPlatformVolcengine TaskPlatform = "volcengine"
TaskPlatformAli TaskPlatform = "ali"
TaskPlatformGemini TaskPlatform = "gemini"
TaskPlatformMiniMax TaskPlatform = "minimax"
// TaskPlatformSyncImage 同步图片厂商异步化专用平台标记。
// 注意:故意不加入 ProviderTypeToTaskPlatform —— 它由 sync_image worker 池执行,
@@ -94,6 +95,8 @@ func ProviderTypeToTaskPlatform(p ProviderType) (TaskPlatform, bool) {
return TaskPlatformAli, true
case ProviderGemini:
return TaskPlatformGemini, true
case ProviderMiniMax:
return TaskPlatformMiniMax, true
default:
return "", false
}
+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 {
+787
View File
@@ -0,0 +1,787 @@
package minimax
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/qianfree/team-api/relay/common"
"github.com/qianfree/team-api/relay/constant"
"github.com/qianfree/team-api/relay/taskchannel"
)
// MiniMax 视频生成任务适配器,覆盖两代协议(按上游模型名分派,见 IsV2Model):
// - v2(MiniMax-H3 系列):POST /v2/video_generation 提交(content[] 多模态)→
// GET /v2/query/video_generation/{task_id} 轮询,错误为 OpenAI 风格 OaiError;
// - v1(Hailuo 2.x 及旧型号):POST /v1/video_generation 提交(扁平字段)→
// GET /v1/query/video_generation?task_id= 轮询,错误为 base_resp 信封
// (HTTP 200 且 status_code != 0 即业务失败),成功后需二跳
// GET /v1/files/retrieve?file_id= 换取 download_url(FetchTask 内链式完成)。
//
// 请求体支持两种入站形态(由 BuildRequestBody 分流):
// - 原生形态:v2 顶层 content[] 数组 / v1 顶层 first_frame_image 等扁平字段(官方协议入站);
// - 通用形态:{model, prompt, seconds, images[], metadata{...}} 归一化任务体
// (/v1/videos 与 /v1/video/generations 入站)。
func init() {
taskchannel.Register(constant.ProviderMiniMax, func() common.TaskAdaptor {
return &Adaptor{}
})
}
// ==================== 请求/响应结构体 ====================
// submitResponse v2 提交任务响应(VideoGenerationV2Resp)
type submitResponse struct {
TaskID string `json:"task_id"`
}
// baseResp v1 接口的业务状态信封(HTTP 200 且 status_code != 0 表示业务失败)
type baseResp struct {
StatusCode int `json:"status_code"`
StatusMsg string `json:"status_msg"`
}
// submitResponseV1 v1 提交任务响应(VideoGenerationResp)
type submitResponseV1 struct {
TaskID string `json:"task_id"`
BaseResp *baseResp `json:"base_resp"`
}
// taskQueryResponseV1 v1 任务查询响应(QueryVideoGenerationTaskResp)。
// DownloadURL 为 FetchTask 二跳 files/retrieve 后合并进来的字段(非上游查询接口原生字段)。
type taskQueryResponseV1 struct {
TaskID string `json:"task_id"`
Status string `json:"status"`
FileID string `json:"file_id"`
VideoWidth int `json:"video_width"`
VideoHeight int `json:"video_height"`
DownloadURL string `json:"download_url,omitempty"`
BaseResp *baseResp `json:"base_resp"`
}
// fileRetrieveResponse v1 文件查询响应(RetrieveFileResp)
type fileRetrieveResponse struct {
File struct {
FileID int `json:"file_id"`
DownloadURL string `json:"download_url"`
} `json:"file"`
BaseResp *baseResp `json:"base_resp"`
}
// oaiErrorBody v2 接口错误结构(OpenAI 风格顶层 error 对象)
type oaiErrorBody struct {
Error struct {
Type string `json:"type"`
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
// IsV2Model 判断模型是否走 v2 H3 协议(content[] 多模态)。
// 其余模型(Hailuo 2.x 及 T2V/I2V/S2V 旧型号)一律走 v1 扁平协议——
// 官方 v2 接口明确「当前支持模型:MiniMax-H3」,未列型号默认按 v1 处理。
func IsV2Model(model string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(model)), "minimax-h3")
}
// ==================== Adaptor 实现 ====================
// Adaptor MiniMax H3 视频任务适配器
type Adaptor struct {
info *common.RelayInfo
}
func (a *Adaptor) Init(info *common.RelayInfo) {
a.info = info
}
func (a *Adaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, body []byte) *common.TaskError {
var req map[string]any
if err := json.Unmarshal(body, &req); err != nil {
return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid request body"}
}
// v2 原生形态:官方约束每次请求必须包含一个非空 text 项
if content, ok := req["content"].([]any); ok {
if !contentHasText(content) {
return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "content must contain a non-empty text item"}
}
return nil
}
// v1 原生形态:文生视频 prompt 必填、图生视频 first_frame_image 必填、主体参考 subject_reference 必填(三选一)
if hasAnyKey(req, "first_frame_image", "last_frame_image", "subject_reference") {
if !hasAnyKey(req, "prompt", "first_frame_image", "subject_reference") {
return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "one of prompt / first_frame_image / subject_reference is required"}
}
return nil
}
// 通用形态:prompt 必填(上游 text 项 / v1 prompt 的唯一来源)
if prompt, ok := req["prompt"].(string); !ok || strings.TrimSpace(prompt) == "" {
return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "prompt is required"}
}
return nil
}
// EstimateBilling 估算计费上下文。
// 只写 spec.* 规格事实值(per_second 计费矩阵查价键),禁止附加 float 乘数键——
// ratios 中所有 float64 值会被计费引擎 applyRatioMultipliers 连乘,混入非乘数键会改变费用。
// resolveDuration/resolveResolution 与 BuildRequestBody 共用,保证计费规格与实际生成规格一致。
func (a *Adaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]any {
var req map[string]any
if err := json.Unmarshal(body, &req); err != nil {
req = nil
}
return map[string]any{
"spec.duration": float64(resolveDuration(req)),
"spec.resolution": resolveResolution(req),
}
}
func (a *Adaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]any {
return nil
}
func (a *Adaptor) BuildRequestURL(info *common.RelayInfo) (string, error) {
base := strings.TrimRight(info.ChannelMeta.BaseURL, "/")
if IsV2Model(dispatchModel(info)) {
return base + "/v2/video_generation", nil
}
return base + "/v1/video_generation", nil
}
// dispatchModel 协议分派用的模型名(上游映射名优先,缺省回落请求模型名)
func dispatchModel(info *common.RelayInfo) string {
if info.ChannelMeta.UpstreamModelName != "" {
return info.ChannelMeta.UpstreamModelName
}
return info.OriginModelName
}
func (a *Adaptor) BuildRequestHeader(header http.Header, info *common.RelayInfo) error {
header.Set("Authorization", "Bearer "+info.ChannelMeta.ApiKey)
header.Set("Content-Type", "application/json")
header.Set("Accept", "application/json")
return nil
}
func (a *Adaptor) BuildRequestBody(_ context.Context, info *common.RelayInfo, body []byte) (io.Reader, error) {
var req map[string]any
if err := json.Unmarshal(body, &req); err != nil {
return nil, fmt.Errorf("minimax: invalid request body: %w", err)
}
modelName := resolveUpstreamModel(info, req)
var out map[string]any
if IsV2Model(modelName) {
out = buildV2RequestBody(req, modelName)
} else {
out = buildV1RequestBody(req, modelName)
}
data, err := json.Marshal(out)
if err != nil {
return nil, fmt.Errorf("marshal minimax request: %w", err)
}
return strings.NewReader(string(data)), nil
}
// buildV2RequestBody v2(H3 系列)请求体:content[] 多模态。
// 原生形态(顶层 content[])原样透传;通用形态从 prompt/images/metadata 组装。
// 注意不透传 callback_url——上游回调携带上游 task_id,与网关公开 ID 对不上且会绕过网关轮询计费。
func buildV2RequestBody(req map[string]any, modelName string) map[string]any {
out := map[string]any{
"model": modelName,
"resolution": resolveResolution(req),
"duration": resolveDuration(req),
}
if content, ok := req["content"].([]any); ok && len(content) > 0 {
out["content"] = content
if ratio := resolveRatio(req); ratio != "" {
out["ratio"] = ratio
}
} else {
content := buildGenericContent(req)
if len(content) == 0 {
content = []map[string]any{{"type": "text", "text": ""}}
}
out["content"] = content
// t2v(纯文本输入)时官方要求 ratio 必填且不能为 adaptive;含图片输入时上游恒按 adaptive 处理,显式传值无害
ratio := resolveRatio(req)
if ratio == "" {
ratio = defaultTextOnlyRatio
}
out["ratio"] = ratio
}
if wm, ok := req["aigc_watermark"].(bool); ok {
out["aigc_watermark"] = wm
}
return out
}
// buildV1RequestBody v1(Hailuo 系列)请求体:扁平字段。
// v1 原生入站的顶层字段(first_frame_image/last_frame_image/subject_reference/prompt_optimizer/
// fast_pretreatment)与通用词汇(images[0]/metadata.image/metadata.last_frame 等)统一在此归并,
// 白名单式拷贝保证 callback_url 等网关侧字段不会外泄到上游。
func buildV1RequestBody(req map[string]any, modelName string) map[string]any {
out := map[string]any{
"model": modelName,
"resolution": resolveResolution(req),
"duration": resolveDuration(req),
}
if prompt, ok := req["prompt"].(string); ok && strings.TrimSpace(prompt) != "" {
out["prompt"] = prompt
}
// 首帧图:v1 原生顶层字段 > images[0]/metadata.image
firstFrame := stringField(req, "first_frame_image")
if firstFrame == "" {
firstFrame = genericFirstImage(req)
}
if firstFrame != "" {
out["first_frame_image"] = firstFrame
}
// 尾帧图:v1 原生顶层字段 > metadata.last_frame
lastFrame := stringField(req, "last_frame_image")
if lastFrame == "" {
lastFrame = metaString(req, "last_frame")
}
if lastFrame != "" {
out["last_frame_image"] = lastFrame
}
// 主体参考(S2V-01):v1 原生顶层字段 > metadata.subject_reference
if sr, ok := req["subject_reference"]; ok && sr != nil {
out["subject_reference"] = sr
} else if sr := metaAny(req, "subject_reference"); sr != nil {
out["subject_reference"] = sr
}
// 可选参数白名单透传
for _, key := range []string{"prompt_optimizer", "fast_pretreatment", "aigc_watermark"} {
if v, ok := req[key]; ok {
out[key] = v
}
}
return out
}
func (a *Adaptor) DoRequest(_ context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) {
reqURL, err := a.BuildRequestURL(info)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPost, reqURL, requestBody)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
if err := a.BuildRequestHeader(req.Header, info); err != nil {
return nil, fmt.Errorf("setup header: %w", err)
}
client := common.NewPooledClient(120, info.ChannelMeta.Settings.UseProxy)
return client.Do(req)
}
func (a *Adaptor) DoResponse(_ context.Context, resp *http.Response, info *common.RelayInfo) (string, []byte, *common.TaskError) {
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", nil, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "read response failed"}
}
if resp.StatusCode != http.StatusOK {
msg := strings.TrimSpace(string(body))
// v2 用 OaiError、v1 用 base_resp,两种信封都尝试解析
var errResp oaiErrorBody
if json.Unmarshal(body, &errResp) == nil && errResp.Error.Message != "" {
msg = errResp.Error.Message
} else {
var baseRespErr struct {
BaseResp *baseResp `json:"base_resp"`
}
if json.Unmarshal(body, &baseRespErr) == nil && baseRespErr.BaseResp != nil && baseRespErr.BaseResp.StatusMsg != "" {
msg = baseRespErr.BaseResp.StatusMsg
}
}
if msg == "" {
msg = fmt.Sprintf("upstream returned status %d", resp.StatusCode)
}
return "", body, &common.TaskError{StatusCode: resp.StatusCode, Message: msg}
}
if IsV2Model(dispatchModel(info)) {
var result submitResponse
if err := json.Unmarshal(body, &result); err != nil {
return "", body, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "parse response failed"}
}
if result.TaskID == "" {
return "", body, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "upstream returned empty task id"}
}
return result.TaskID, body, nil
}
// v1:HTTP 200 仍需检查 base_resp 业务码(status_code != 0 即业务失败)
var result submitResponseV1
if err := json.Unmarshal(body, &result); err != nil {
return "", body, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "parse response failed"}
}
if result.BaseResp != nil && result.BaseResp.StatusCode != 0 {
msg := result.BaseResp.StatusMsg
if msg == "" {
msg = fmt.Sprintf("upstream business error %d", result.BaseResp.StatusCode)
}
return "", body, &common.TaskError{StatusCode: http.StatusBadRequest, Message: msg, ErrCode: fmt.Sprintf("%d", result.BaseResp.StatusCode)}
}
if result.TaskID == "" {
return "", body, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "upstream returned empty task id"}
}
return result.TaskID, body, nil
}
func (a *Adaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http.Response, error) {
var data struct {
TaskID string `json:"task_id"`
UseProxy bool `json:"use_proxy"`
Model string `json:"model"` // 轮询器注入的上游模型名,用于 v1/v2 协议分派
}
if err := json.Unmarshal(taskData, &data); err != nil {
return nil, fmt.Errorf("minimax: invalid task data: %w", err)
}
base := strings.TrimRight(baseURL, "/")
if IsV2Model(data.Model) {
return fetchV2Task(base, apiKey, data.TaskID, data.UseProxy)
}
return fetchV1Task(base, apiKey, data.TaskID, data.UseProxy)
}
// fetchV2Task v2 单任务查询:GET /v2/query/video_generation/{task_id}
func fetchV2Task(base, apiKey, taskID string, useProxy bool) (*http.Response, error) {
url := fmt.Sprintf("%s/v2/query/video_generation/%s", base, taskID)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
client := common.NewPooledClient(30, useProxy)
return client.Do(req)
}
// fetchV1Task v1 单任务查询:GET /v1/query/video_generation?task_id=...。
// v1 协议成功后只给 file_id,需二跳 GET /v1/files/retrieve?file_id=... 换取限时 download_url——
// 在此链式完成并把 download_url 合并进查询响应体(ParseTaskResult 与原生查询回放共用该字段)。
// 二跳失败不改变任务状态判定:任务仍按 Success 结算,仅缺下载直链(客户端可稍后重查)。
func fetchV1Task(base, apiKey, taskID string, useProxy bool) (*http.Response, error) {
client := common.NewPooledClient(30, useProxy)
queryURL := fmt.Sprintf("%s/v1/query/video_generation?task_id=%s", base, url.QueryEscape(taskID))
req, err := http.NewRequest(http.MethodGet, queryURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
// 仅在成功且拿到 file_id 时二跳;其余状态原样返回
var probe taskQueryResponseV1
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &probe) != nil || probe.Status != "Success" || probe.FileID == "" {
return rebuiltResponse(resp.StatusCode, body), nil
}
fileURL := fmt.Sprintf("%s/v1/files/retrieve?file_id=%s", base, url.QueryEscape(probe.FileID))
fileReq, err := http.NewRequest(http.MethodGet, fileURL, nil)
if err != nil {
return rebuiltResponse(resp.StatusCode, body), nil
}
fileReq.Header.Set("Authorization", "Bearer "+apiKey)
fileResp, err := client.Do(fileReq)
if err != nil {
return rebuiltResponse(resp.StatusCode, body), nil
}
defer fileResp.Body.Close()
fileBody, err := io.ReadAll(fileResp.Body)
if err != nil {
return rebuiltResponse(resp.StatusCode, body), nil
}
var fileResult fileRetrieveResponse
if fileResp.StatusCode != http.StatusOK || json.Unmarshal(fileBody, &fileResult) != nil ||
fileResult.File.DownloadURL == "" ||
(fileResult.BaseResp != nil && fileResult.BaseResp.StatusCode != 0) {
// 二跳失败:保留原查询响应(download_url 缺失),任务仍可按 Success 结算
return rebuiltResponse(resp.StatusCode, body), nil
}
// 合并 download_url 进查询响应体
var merged map[string]any
if json.Unmarshal(body, &merged) == nil {
merged["download_url"] = fileResult.File.DownloadURL
if newBody, err := json.Marshal(merged); err == nil {
return rebuiltResponse(resp.StatusCode, newBody), nil
}
}
return rebuiltResponse(resp.StatusCode, body), nil
}
// rebuiltResponse 用已消费的 body 重建 http.Response(FetchTask 契约要求返回未读响应体)
func rebuiltResponse(statusCode int, body []byte) *http.Response {
return &http.Response{
StatusCode: statusCode,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(bytes.NewReader(body)),
}
}
// ParseTaskResult 解析上游单任务查询响应。两代协议按响应形态自动区分:
// v2 为 {"task": {...}} 包装、v1 为扁平的 {task_id, status, file_id, ...}。
// 注意:不填 ActualCost——per_second 计费下 RecalculateByTokens 返回 0,结算保持预扣金额,
// 而 duration 必填=产物时长,预扣即终价;填入 ActualCost 反而会用上游口径覆盖平台定价。
func (a *Adaptor) ParseTaskResult(body []byte) (*common.TaskInfo, error) {
var resp TaskQueryResponse
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("minimax: parse task result: %w", err)
}
if resp.Task != nil {
return parseV2TaskResult(body, resp.Task)
}
return parseV1TaskResult(body)
}
// parseV2TaskResult v2(H3)查询响应解析
func parseV2TaskResult(body []byte, task *VideoTask) (*common.TaskInfo, error) {
info := &common.TaskInfo{Data: body}
// 上游返回的 token 用量仅作记录(per_second 计费不按 token 结算)
if task.Usage != nil && task.Usage.TotalTokens > 0 {
info.TotalTokens = task.Usage.TotalTokens
info.CompletionTokens = task.Usage.CompletionTokens
info.PromptTokens = task.Usage.PromptTokens
}
switch task.Status {
case "queued":
info.Status = common.TaskStatusQueued
info.Progress = "10%"
case "running":
info.Status = common.TaskStatusInProgress
info.Progress = "50%"
case "succeeded":
info.Status = common.TaskStatusSuccess
info.Progress = "100%"
if task.Content != nil {
info.ResultURL = task.Content.URL
}
case "failed":
info.Status = common.TaskStatusFailure
info.FailReason = "video generation failed"
if task.Error != nil && task.Error.Message != "" {
info.FailReason = task.Error.Message
}
case "cancelled":
// 必须映射为终态 FAILURE:映射成非终态会让任务挂到超时兜底(30 分钟)才退款
info.Status = common.TaskStatusFailure
info.FailReason = "task cancelled"
default:
info.Status = common.TaskStatusInProgress
info.Progress = "30%"
}
return info, nil
}
// parseV1TaskResult v1(Hailuo)查询响应解析(状态机 Preparing/Queueing/Processing/Success/Fail)。
// download_url 为 FetchTask 二跳合并字段,成功时作为 ResultURL。
func parseV1TaskResult(body []byte) (*common.TaskInfo, error) {
var resp taskQueryResponseV1
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("minimax: parse v1 task result: %w", err)
}
info := &common.TaskInfo{Data: body}
switch resp.Status {
case "Preparing", "Queueing":
info.Status = common.TaskStatusQueued
info.Progress = "10%"
case "Processing":
info.Status = common.TaskStatusInProgress
info.Progress = "50%"
case "Success":
info.Status = common.TaskStatusSuccess
info.Progress = "100%"
info.ResultURL = resp.DownloadURL
case "Fail":
info.Status = common.TaskStatusFailure
info.FailReason = "video generation failed"
if resp.BaseResp != nil && resp.BaseResp.StatusMsg != "" {
info.FailReason = resp.BaseResp.StatusMsg
}
default:
info.Status = common.TaskStatusInProgress
info.Progress = "30%"
}
return info, nil
}
func (a *Adaptor) GetModelList() []string {
return ModelList
}
func (a *Adaptor) GetChannelName() string {
return channelName
}
// ==================== 归一化辅助函数 ====================
// 以下函数被 EstimateBilling 与 BuildRequestBody 共用(计费一致性强约束):
// per_second 矩阵键按 spec.resolution 原值逐字符匹配,两处归一化若不一致会导致
// 「按 768P 生成、按兜底价收费」的系统性少收。
// resolveDuration 提取时长秒数:顶层 duration(原生形态)> seconds > metadata.duration > 默认 6。
func resolveDuration(req map[string]any) int {
if d, ok := parseIntAny(req["duration"]); ok && d > 0 {
return d
}
if d, ok := parseIntAny(req["seconds"]); ok && d > 0 {
return d
}
metadata := taskchannel.ExtractMetadata(req)
var meta struct {
Duration *int `json:"duration"`
}
if taskchannel.UnmarshalMetadata(metadata, &meta) == nil && meta.Duration != nil && *meta.Duration > 0 {
return *meta.Duration
}
return defaultDurationSecs
}
// resolveResolution 提取分辨率:顶层 resolution > metadata.resolution > metadata.size > 按模型默认档。
// size 回退覆盖 /v1/videos 入站(该协议的分辨率就承载在 size 字段,原值透传,无换算)。
// 默认档跟随官方口径:H3 与 Hailuo 2.x 默认 768P,T2V/I2V/S2V 旧型号默认 720P。
func resolveResolution(req map[string]any) string {
if r, ok := req["resolution"].(string); ok && strings.TrimSpace(r) != "" {
return normalizeResolution(r)
}
metadata := taskchannel.ExtractMetadata(req)
var meta struct {
Resolution string `json:"resolution"`
Size string `json:"size"`
}
if taskchannel.UnmarshalMetadata(metadata, &meta) == nil {
if strings.TrimSpace(meta.Resolution) != "" {
return normalizeResolution(meta.Resolution)
}
if strings.TrimSpace(meta.Size) != "" {
return normalizeResolution(meta.Size)
}
}
model, _ := req["model"].(string)
return defaultResolutionFor(model)
}
// defaultResolutionFor 按模型取分辨率默认档(H3/Hailuo 2.x → 768P,其余 → 720P)
func defaultResolutionFor(model string) string {
m := strings.ToLower(strings.TrimSpace(model))
if strings.HasPrefix(m, "minimax-h3") || strings.HasPrefix(m, "minimax-hailuo-2") {
return defaultResolution
}
return defaultResolutionV1
}
// resolveRatio 提取宽高比:顶层 ratio > metadata.ratio > metadata.aspect_ratio(kling 词汇);空 = 未指定。
func resolveRatio(req map[string]any) string {
if r, ok := req["ratio"].(string); ok && strings.TrimSpace(r) != "" {
return r
}
metadata := taskchannel.ExtractMetadata(req)
var meta struct {
Ratio string `json:"ratio"`
AspectRatio string `json:"aspect_ratio"`
}
if taskchannel.UnmarshalMetadata(metadata, &meta) == nil {
if meta.Ratio != "" {
return meta.Ratio
}
if meta.AspectRatio != "" {
return meta.AspectRatio
}
}
return ""
}
// normalizeResolution 分辨率值规范:仅做两代协议官方档位的大小写规范(per_second 计费矩阵键
// 逐字符匹配,大小写差异会导致查价错档),**不做任何档位换算**——非官方档位的值
// 原样透传,由上游裁决(非法值上游 400,错误透传给客户端并全额退款)。
// 官方档位全集:v2 的 480P/768P/2K + v1 的 720P/768P/1080P/512P。
func normalizeResolution(v string) string {
switch strings.ToLower(strings.TrimSpace(v)) {
case "480p":
return "480P"
case "720p":
return "720P"
case "768p":
return "768P"
case "1080p":
return "1080P"
case "512p":
return "512P"
case "2k":
return "2K"
}
return strings.TrimSpace(v)
}
// parseIntAny 宽松解析整数(JSON number 解码为 float64,或字符串数字)
func parseIntAny(v any) (int, bool) {
switch n := v.(type) {
case float64:
if n > 0 {
return int(n), true
}
case string:
if i, err := strconv.Atoi(strings.TrimSpace(n)); err == nil && i > 0 {
return i, true
}
}
return 0, false
}
// buildGenericContent 从归一化任务体组装 MiniMax content[]:
// prompt → text 项;images[0]/metadata.image → 首帧图;metadata.last_frame → 尾帧图;
// metadata.content[] → 追加透传(多模态参考,与 volcengine 适配器同词汇)。
func buildGenericContent(req map[string]any) []map[string]any {
content := make([]map[string]any, 0, 4)
if prompt, ok := req["prompt"].(string); ok && strings.TrimSpace(prompt) != "" {
content = append(content, map[string]any{"type": "text", "text": prompt})
}
metadata := taskchannel.ExtractMetadata(req)
var meta struct {
Image string `json:"image"`
LastFrame string `json:"last_frame"`
Content []map[string]any `json:"content"`
}
_ = taskchannel.UnmarshalMetadata(metadata, &meta)
firstFrame := meta.Image
if firstFrame == "" {
if images, ok := req["images"].([]any); ok && len(images) > 0 {
if url, ok := images[0].(string); ok {
firstFrame = url
}
}
}
if firstFrame != "" {
content = append(content, map[string]any{
"type": "image_url",
"image_url": map[string]any{"url": firstFrame},
"role": "first_frame",
})
}
if meta.LastFrame != "" {
content = append(content, map[string]any{
"type": "image_url",
"image_url": map[string]any{"url": meta.LastFrame},
"role": "last_frame",
})
}
content = append(content, meta.Content...)
return content
}
// contentHasText 判断原生 content[] 是否含非空 text 项
func contentHasText(content []any) bool {
for _, item := range 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) != "" {
return true
}
}
}
return false
}
// resolveUpstreamModel 解析上游模型名(渠道模型映射优先)
func resolveUpstreamModel(info *common.RelayInfo, req map[string]any) string {
if info.ChannelMeta.IsModelMapped && info.ChannelMeta.UpstreamModelName != "" {
return info.ChannelMeta.UpstreamModelName
}
if m, ok := req["model"].(string); ok {
return m
}
return ""
}
// ==================== v1 归并辅助 ====================
// hasAnyKey 判断请求 map 是否含任一指定键(值非空)
func hasAnyKey(req map[string]any, keys ...string) bool {
for _, key := range keys {
if v, ok := req[key]; ok && v != nil {
if s, isStr := v.(string); !isStr || strings.TrimSpace(s) != "" {
return true
}
}
}
return false
}
// stringField 取顶层字符串字段(非空)
func stringField(req map[string]any, key string) string {
if s, ok := req[key].(string); ok {
return strings.TrimSpace(s)
}
return ""
}
// metaString 取 metadata 内字符串字段(非空)
func metaString(req map[string]any, key string) string {
if v, ok := taskchannel.ExtractMetadata(req)[key].(string); ok {
return strings.TrimSpace(v)
}
return ""
}
// metaAny 取 metadata 内任意类型字段(存在则返回)
func metaAny(req map[string]any, key string) any {
metadata := taskchannel.ExtractMetadata(req)
if v, ok := metadata[key]; ok {
return v
}
return nil
}
// genericFirstImage 取通用形态的首帧图:images[0] > metadata.image
func genericFirstImage(req map[string]any) string {
if images, ok := req["images"].([]any); ok && len(images) > 0 {
if url, ok := images[0].(string); ok {
return strings.TrimSpace(url)
}
}
if v, ok := taskchannel.ExtractMetadata(req)["image"].(string); ok {
return strings.TrimSpace(v)
}
return ""
}
+630
View File
@@ -0,0 +1,630 @@
package minimax
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"
)
// testInfo 构造带渠道元数据的 RelayInfo
func testInfo(baseURL, upstreamModel string, mapped bool) *common.RelayInfo {
return &common.RelayInfo{
ChannelMeta: &common.ChannelMeta{
ChannelID: 1,
ChannelType: int(constant.ProviderMiniMax),
BaseURL: baseURL,
ApiKey: "sk-test",
UpstreamModelName: upstreamModel,
IsModelMapped: mapped,
},
}
}
func buildBody(t *testing.T, a *Adaptor, info *common.RelayInfo, body string) map[string]any {
t.Helper()
reader, err := a.BuildRequestBody(context.Background(), info, []byte(body))
if err != nil {
t.Fatalf("build request body: %v", err)
}
raw, _ := io.ReadAll(reader)
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("upstream body not json: %v, raw=%s", err, string(raw))
}
return out
}
// TestNormalizeResolution 仅做两代协议官方档位的大小写规范(计费矩阵键逐字符匹配),
// 非官方值原样透传、由上游裁决——不做任何档位换算。
func TestNormalizeResolution(t *testing.T) {
cases := map[string]string{
"480P": "480P",
"480p": "480P",
" 480P ": "480P",
"720p": "720P",
"768P": "768P",
"768p": "768P",
"1080p": "1080P", // v1 官方档位:仅规范大小写
"512p": "512P",
"2K": "2K",
"2k": "2K",
"1280x720": "1280x720", // 非官方档位:原样透传(上游 400 + 退款)
"custom-tier": "custom-tier",
"": "",
}
for in, want := range cases {
if got := normalizeResolution(in); got != want {
t.Errorf("normalizeResolution(%q) = %q, want %q", in, got, want)
}
}
}
func TestValidateRequest(t *testing.T) {
a := &Adaptor{}
// 通用形态:prompt 必填
if err := a.ValidateRequest(context.Background(), nil, []byte(`{"model":"MiniMax-H3"}`)); err == nil {
t.Error("expected error when prompt missing in generic form")
}
if err := a.ValidateRequest(context.Background(), nil, []byte(`{"model":"MiniMax-H3","prompt":"hi"}`)); err != nil {
t.Errorf("generic form with prompt should pass: %v", err)
}
// 原生形态:content 必含非空 text 项
if err := a.ValidateRequest(context.Background(), nil, []byte(`{"model":"MiniMax-H3","content":[{"type":"image_url","image_url":{"url":"https://x/a.png"}}]}`)); err == nil {
t.Error("expected error when native content has no text item")
}
if err := a.ValidateRequest(context.Background(), nil, []byte(`{"model":"MiniMax-H3","content":[{"type":"text","text":" "}]}`)); err == nil {
t.Error("expected error when native content text item is blank")
}
if err := a.ValidateRequest(context.Background(), nil, []byte(`{"model":"MiniMax-H3","content":[{"type":"text","text":"海浪"},{"type":"image_url","image_url":{"url":"https://x/a.png"}}]}`)); err != nil {
t.Errorf("native form with text should pass: %v", err)
}
}
// TestEstimateBilling_OnlySpecKeys 计费上下文只能包含 spec.* 两个键:
// ratios 里的 float64 值会被计费引擎连乘,混入非乘数键即资损向量。
func TestEstimateBilling_OnlySpecKeys(t *testing.T) {
a := &Adaptor{}
ratios := a.EstimateBilling(context.Background(), nil, []byte(`{"model":"MiniMax-H3","prompt":"hi","seconds":"8","metadata":{"size":"2K"}}`))
if len(ratios) != 2 {
t.Fatalf("expected exactly 2 ratio keys, got %d: %v", len(ratios), ratios)
}
if d, ok := ratios["spec.duration"].(float64); !ok || d != 8 {
t.Fatalf("spec.duration = %v (%T), want float64(8)", ratios["spec.duration"], ratios["spec.duration"])
}
if r := ratios["spec.resolution"]; r != "2K" {
t.Fatalf("spec.resolution = %v, want 2K", r)
}
}
// TestBillingBuildConsistency EstimateBilling 与 BuildRequestBody 必须共用同一归一化:
// 同一输入下,计费 spec 值必须与上游实际收到的规格一致(矩阵键逐字符匹配)。
func TestBillingBuildConsistency(t *testing.T) {
a := &Adaptor{}
inputs := []string{
`{"model":"MiniMax-H3","prompt":"hi","seconds":"8","metadata":{"resolution":"1080p"}}`,
`{"model":"MiniMax-H3","prompt":"hi","metadata":{"resolution":"1280x720","duration":4}}`,
`{"model":"MiniMax-H3","prompt":"hi"}`,
`{"model":"MiniMax-H3","content":[{"type":"text","text":"hi"}],"resolution":"2K","duration":15}`,
`{"model":"MiniMax-H3-Max","content":[{"type":"text","text":"hi"}],"resolution":"480P","duration":5}`,
// /v1/videos 入站:size 命名档位(未经四档映射,仅 size 原值透传)走回退归一化
`{"model":"MiniMax-H3","prompt":"hi","seconds":"6","metadata":{"size":"2K"}}`,
`{"model":"MiniMax-H3","prompt":"hi","seconds":"6","metadata":{"size":"768P"}}`,
}
for _, in := range inputs {
ratios := a.EstimateBilling(context.Background(), nil, []byte(in))
out := buildBody(t, a, testInfo("", "MiniMax-H3", false), in)
billDur := int(ratios["spec.duration"].(float64))
if upDur := int(out["duration"].(float64)); billDur != upDur {
t.Errorf("input=%s: billing duration %d != upstream duration %d", in, billDur, upDur)
}
if ratios["spec.resolution"] != out["resolution"] {
t.Errorf("input=%s: billing resolution %v != upstream resolution %v", in, ratios["spec.resolution"], out["resolution"])
}
}
}
// TestResolveResolution_SizeFallback metadata.resolution 优先于 metadata.size;
// 仅 size 存在时作为分辨率信号(/v1/videos 的分辨率承载在 size 字段,原值透传)。
func TestResolveResolution_SizeFallback(t *testing.T) {
cases := []struct {
name string
req map[string]any
want string
}{
{"resolution wins", map[string]any{"metadata": map[string]any{"resolution": "2K", "size": "768P"}}, "2K"},
{"size named tier", map[string]any{"metadata": map[string]any{"size": "768P"}}, "768P"},
{"size lowercase tier", map[string]any{"metadata": map[string]any{"size": "2k"}}, "2K"},
{"size non-tier passthrough", map[string]any{"metadata": map[string]any{"size": "1536p"}}, "1536p"},
{"top-level wins all", map[string]any{"resolution": "480P", "metadata": map[string]any{"resolution": "2K", "size": "768P"}}, "480P"},
{"default for h3 model", map[string]any{"model": "MiniMax-H3"}, "768P"},
{"default for hailuo model", map[string]any{"model": "MiniMax-Hailuo-2.3"}, "768P"},
{"default for legacy model", map[string]any{"model": "T2V-01"}, "720P"},
}
for _, c := range cases {
if got := resolveResolution(c.req); got != c.want {
t.Errorf("%s: resolveResolution = %q, want %q", c.name, got, c.want)
}
}
}
func TestBuildRequestBody_NativePassthrough(t *testing.T) {
a := &Adaptor{}
body := `{
"model": "MiniMax-H3",
"content": [
{"type": "text", "text": "海浪拍打礁石"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,xxx"}, "role": "first_frame"},
{"type": "video_url", "video_url": {"url": "https://cdn.example/ref.mp4"}, "role": "reference_video"}
],
"resolution": "2K",
"duration": 10,
"ratio": "16:9",
"aigc_watermark": true,
"callback_url": "https://evil.example/cb"
}`
out := buildBody(t, a, testInfo("", "MiniMax-H3-Upstream", true), body)
// 模型映射 + content 原样透传 + 顶层参数
if out["model"] != "MiniMax-H3-Upstream" {
t.Fatalf("upstream model: %v", out["model"])
}
if out["resolution"] != "2K" || out["duration"] != float64(10) || out["ratio"] != "16:9" {
t.Fatalf("upstream params: resolution=%v duration=%v ratio=%v", out["resolution"], out["duration"], out["ratio"])
}
if wm, _ := out["aigc_watermark"].(bool); !wm {
t.Fatalf("aigc_watermark should pass through: %v", out["aigc_watermark"])
}
// callback_url 必须被剥离
if _, has := out["callback_url"]; has {
t.Fatal("callback_url must be stripped from upstream request")
}
content, ok := out["content"].([]any)
if !ok || len(content) != 3 {
t.Fatalf("expected 3 content items, got %v", out["content"])
}
img, _ := content[1].(map[string]any)
if img["role"] != "first_frame" {
t.Fatalf("content roles must be preserved: %v", content[1])
}
vid, _ := content[2].(map[string]any)
if vid["type"] != "video_url" {
t.Fatalf("multimodal reference item must be preserved: %v", content[2])
}
}
func TestBuildRequestBody_GenericForm(t *testing.T) {
a := &Adaptor{}
body := `{
"model": "MiniMax-H3",
"prompt": "小猫对着镜头打哈欠",
"seconds": "8",
"images": ["https://cdn.example/first.png"],
"metadata": {"resolution": "2K", "ratio": "9:16", "last_frame": "https://cdn.example/last.png"}
}`
out := buildBody(t, a, testInfo("", "", false), body)
if out["duration"] != float64(8) || out["resolution"] != "2K" || out["ratio"] != "9:16" {
t.Fatalf("mapped params: duration=%v resolution=%v ratio=%v", out["duration"], out["resolution"], out["ratio"])
}
content, ok := out["content"].([]any)
if !ok || len(content) != 3 {
t.Fatalf("expected text + first_frame + last_frame, got %v", out["content"])
}
text, _ := content[0].(map[string]any)
if text["type"] != "text" || text["text"] != "小猫对着镜头打哈欠" {
t.Fatalf("text item: %v", content[0])
}
first, _ := content[1].(map[string]any)
if first["role"] != "first_frame" {
t.Fatalf("first frame role: %v", content[1])
}
last, _ := content[2].(map[string]any)
if last["role"] != "last_frame" {
t.Fatalf("last frame role: %v", content[2])
}
}
// TestBuildRequestBody_GenericDefaults 纯文生视频缺省值:duration=6、resolution=768P、ratio=16:9
func TestBuildRequestBody_GenericDefaults(t *testing.T) {
a := &Adaptor{}
out := buildBody(t, a, testInfo("", "", false), `{"model":"MiniMax-H3","prompt":"hi"}`)
if out["duration"] != float64(6) || out["resolution"] != "768P" || out["ratio"] != "16:9" {
t.Fatalf("defaults: duration=%v resolution=%v ratio=%v", out["duration"], out["resolution"], out["ratio"])
}
content, _ := out["content"].([]any)
if len(content) != 1 {
t.Fatalf("expected text-only content, got %v", out["content"])
}
}
// TestBuildRequestBody_MetadataContentAppend metadata.content 多模态参考项透传追加
func TestBuildRequestBody_MetadataContentAppend(t *testing.T) {
a := &Adaptor{}
body := `{
"model": "MiniMax-H3",
"prompt": "hi",
"metadata": {
"content": [{"type": "audio_url", "audio_url": {"url": "https://cdn.example/vo.mp3"}, "role": "reference_audio"}]
}
}`
out := buildBody(t, a, testInfo("", "", false), body)
content, _ := out["content"].([]any)
if len(content) != 2 {
t.Fatalf("expected text + appended reference item, got %v", out["content"])
}
audio, _ := content[1].(map[string]any)
if audio["type"] != "audio_url" || audio["role"] != "reference_audio" {
t.Fatalf("appended reference item: %v", content[1])
}
}
// httpResp 构造带 JSON body 的 http.Response 桩
func httpResp(status int, body string) *http.Response {
return &http.Response{
StatusCode: status,
Body: io.NopCloser(strings.NewReader(body)),
}
}
func TestDoResponse(t *testing.T) {
a := &Adaptor{}
info := testInfo("", "MiniMax-H3", false) // v2 分派(DoResponse 按模型走 v1/v2 解析)
// 提交成功
taskID, _, taskErr := a.DoResponse(context.Background(), httpResp(http.StatusOK, `{"task_id":"424010985738629"}`), info)
if taskErr != nil || taskID != "424010985738629" {
t.Fatalf("submit response: id=%q err=%+v", taskID, taskErr)
}
// 上游错误:OaiError 风格 message 透传
_, _, taskErr = a.DoResponse(context.Background(), httpResp(http.StatusBadRequest, `{"error":{"type":"invalid_request_error","message":"resolution not supported"}}`), info)
if taskErr == nil || taskErr.Message != "resolution not supported" || taskErr.StatusCode != http.StatusBadRequest {
t.Fatalf("error response: %+v", taskErr)
}
// 空 task_id
_, _, taskErr = a.DoResponse(context.Background(), httpResp(http.StatusOK, `{"task_id":""}`), info)
if taskErr == nil {
t.Fatal("expected error for empty task id")
}
}
func TestParseTaskResult(t *testing.T) {
a := &Adaptor{}
succeeded := `{"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"}}`
info, err := a.ParseTaskResult([]byte(succeeded))
if err != nil {
t.Fatalf("parse: %v", err)
}
if info.Status != common.TaskStatusSuccess || info.ResultURL != "https://video.cdn.example/out.mp4" {
t.Fatalf("succeeded: %+v", info)
}
if info.TotalTokens != 273890 || info.PromptTokens != 13500 || info.CompletionTokens != 260390 {
t.Fatalf("usage tokens: %+v", info)
}
if info.ActualCost != 0 {
t.Fatalf("ActualCost must stay 0 (per_second 预扣即终价), got %v", info.ActualCost)
}
statuses := map[string]struct {
want common.TaskStatusEnum
reason string
}{
`{"task":{"status":"queued"}}`: {common.TaskStatusQueued, ""},
`{"task":{"status":"running"}}`: {common.TaskStatusInProgress, ""},
`{"task":{"status":"failed","error":{"code":"E1","message":"boom"}}}`: {common.TaskStatusFailure, "boom"},
`{"task":{"status":"cancelled"}}`: {common.TaskStatusFailure, "task cancelled"},
}
for body, want := range statuses {
info, err := a.ParseTaskResult([]byte(body))
if err != nil {
t.Fatalf("parse %s: %v", body, err)
}
if info.Status != want.want {
t.Errorf("%s: status=%v want %v", body, info.Status, want.want)
}
if want.reason != "" && info.FailReason != want.reason {
t.Errorf("%s: failReason=%q want %q", body, info.FailReason, want.reason)
}
if !info.Status.IsTerminal() && (want.want == common.TaskStatusFailure) {
t.Errorf("%s: cancelled/failed must map to terminal state", body)
}
}
// 非 task 包装形态(防御):未知结构降级为进行中,不报错
info, err = a.ParseTaskResult([]byte(`{"task_id":"up-1"}`))
if err != nil || info.Status != common.TaskStatusInProgress {
t.Fatalf("submit-shaped data should degrade to in-progress: %+v err=%v", info, err)
}
}
func TestFetchTask_URL(t *testing.T) {
var gotPath, gotAuth string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotAuth = r.Header.Get("Authorization")
_, _ = w.Write([]byte(`{"task":{"id":"up-1","status":"running"}}`))
}))
defer upstream.Close()
a := &Adaptor{}
taskData, _ := json.Marshal(map[string]any{"task_id": "up-1", "use_proxy": false, "model": "MiniMax-H3"})
resp, err := a.FetchTask(upstream.URL, "sk-test", taskData)
if err != nil {
t.Fatalf("fetch: %v", err)
}
defer resp.Body.Close()
if gotPath != "/v2/query/video_generation/up-1" {
t.Fatalf("fetch path: %s", gotPath)
}
if gotAuth != "Bearer sk-test" {
t.Fatalf("authorization header: %q", gotAuth)
}
}
func TestCancelTask(t *testing.T) {
var gotMethod, gotPath string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"task_id":"424010985738629","action":"cancelled","status":"cancelled"}`))
}))
defer upstream.Close()
result, taskErr := CancelTask(upstream.URL, "sk-test", "424010985738629", false)
if taskErr != nil {
t.Fatalf("cancel: %+v", taskErr)
}
if gotMethod != http.MethodDelete || gotPath != "/v2/video_generation/424010985738629" {
t.Fatalf("cancel request: %s %s", gotMethod, gotPath)
}
if result.Action != "cancelled" || result.Status != "cancelled" {
t.Fatalf("cancel result: %+v", result)
}
}
// TestCancelTask_UpstreamError 上游拒绝(如运行中不可取消):错误码与消息透传
func TestCancelTask_UpstreamError(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":{"type":"invalid_request_error","message":"running task cannot be cancelled"}}`))
}))
defer upstream.Close()
result, taskErr := CancelTask(upstream.URL, "sk-test", "424010985738629", false)
if result != nil || taskErr == nil {
t.Fatalf("expected error, got result=%+v err=%+v", result, taskErr)
}
if taskErr.StatusCode != http.StatusBadRequest || taskErr.Message != "running task cannot be cancelled" {
t.Fatalf("upstream error relay: %+v", taskErr)
}
}
// ==================== v1(Hailuo 系列)协议 ====================
func TestIsV2Model(t *testing.T) {
cases := map[string]bool{
"MiniMax-H3": true,
"MiniMax-H3-Max": true,
"minimax-h3-anything": true,
"MiniMax-Hailuo-2.3": false,
"MiniMax-Hailuo-02": false,
"T2V-01": false,
"S2V-01": false,
"": false,
}
for model, want := range cases {
if got := IsV2Model(model); got != want {
t.Errorf("IsV2Model(%q) = %v, want %v", model, got, want)
}
}
}
func TestBuildRequestBody_V1Generic(t *testing.T) {
a := &Adaptor{}
body := `{
"model": "MiniMax-Hailuo-2.3",
"prompt": "一只猫在月光下的屋顶上行走",
"seconds": "10",
"images": ["https://cdn.example/first.png"],
"metadata": {"resolution": "1080P", "last_frame": "https://cdn.example/last.png"}
}`
out := buildBody(t, a, testInfo("", "", false), body)
if out["model"] != "MiniMax-Hailuo-2.3" || out["prompt"] != "一只猫在月光下的屋顶上行走" {
t.Fatalf("v1 flat fields: %v", out)
}
if out["duration"] != float64(10) || out["resolution"] != "1080P" {
t.Fatalf("v1 duration/resolution: %v/%v", out["duration"], out["resolution"])
}
if out["first_frame_image"] != "https://cdn.example/first.png" {
t.Fatalf("first_frame_image from images[0]: %v", out["first_frame_image"])
}
if out["last_frame_image"] != "https://cdn.example/last.png" {
t.Fatalf("last_frame_image from metadata: %v", out["last_frame_image"])
}
if _, has := out["callback_url"]; has {
t.Fatal("callback_url must never reach upstream")
}
}
// TestBuildRequestBody_V1Native v1 官方入站的扁平字段透传(含可选布尔参数)
func TestBuildRequestBody_V1Native(t *testing.T) {
a := &Adaptor{}
body := `{
"model": "MiniMax-Hailuo-02",
"prompt": "电影感运镜",
"first_frame_image": "https://cdn.example/f.png",
"last_frame_image": "https://cdn.example/l.png",
"duration": 10,
"resolution": "768P",
"prompt_optimizer": false,
"fast_pretreatment": true,
"aigc_watermark": true,
"callback_url": "https://evil.example/cb"
}`
out := buildBody(t, a, testInfo("", "", false), body)
if out["first_frame_image"] != "https://cdn.example/f.png" || out["last_frame_image"] != "https://cdn.example/l.png" {
t.Fatalf("frame images passthrough: %v", out)
}
if po, _ := out["prompt_optimizer"].(bool); po {
t.Fatalf("prompt_optimizer passthrough: %v", out["prompt_optimizer"])
}
if fp, _ := out["fast_pretreatment"].(bool); !fp {
t.Fatalf("fast_pretreatment passthrough: %v", out["fast_pretreatment"])
}
if _, has := out["callback_url"]; has {
t.Fatal("callback_url must never reach upstream")
}
if _, has := out["content"]; has {
t.Fatal("v1 request must not carry content[]")
}
}
// TestBuildRequestBody_V1DefaultResolution 分辨率缺省按模型默认档:Hailuo 2.x → 768P,旧型号 → 720P
func TestBuildRequestBody_V1DefaultResolution(t *testing.T) {
a := &Adaptor{}
out := buildBody(t, a, testInfo("", "", false), `{"model":"MiniMax-Hailuo-2.3","prompt":"hi"}`)
if out["resolution"] != "768P" {
t.Fatalf("hailuo default resolution: %v", out["resolution"])
}
out = buildBody(t, a, testInfo("", "", false), `{"model":"T2V-01","prompt":"hi"}`)
if out["resolution"] != "720P" {
t.Fatalf("legacy model default resolution: %v", out["resolution"])
}
}
func TestDoResponse_V1(t *testing.T) {
a := &Adaptor{}
info := testInfo("", "MiniMax-Hailuo-2.3", false)
// 提交成功(含 base_resp 成功信封)
taskID, _, taskErr := a.DoResponse(context.Background(),
httpResp(http.StatusOK, `{"task_id":"v1-up-1","base_resp":{"status_code":0,"status_msg":"success"}}`), info)
if taskErr != nil || taskID != "v1-up-1" {
t.Fatalf("v1 submit: id=%q err=%+v", taskID, taskErr)
}
// HTTP 200 但 base_resp 业务码非 0:业务失败
_, _, taskErr = a.DoResponse(context.Background(),
httpResp(http.StatusOK, `{"base_resp":{"status_code":2013,"status_msg":"invalid parameters"}}`), info)
if taskErr == nil || taskErr.Message != "invalid parameters" || taskErr.ErrCode != "2013" {
t.Fatalf("v1 business error: %+v", taskErr)
}
}
// TestFetchTask_V1_TwoHop v1 查询:Success 后链式二跳 files/retrieve 换 download_url 并合并进响应体
func TestFetchTask_V1_TwoHop(t *testing.T) {
var queryPath, filePath string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/v1/query/video_generation"):
queryPath = r.URL.Path + "?" + r.URL.RawQuery
_, _ = w.Write([]byte(`{"task_id":"v1-up-1","status":"Success","file_id":"9001","video_width":1280,"video_height":720,"base_resp":{"status_code":0}}`))
case strings.HasPrefix(r.URL.Path, "/v1/files/retrieve"):
filePath = r.URL.Path + "?" + r.URL.RawQuery
_, _ = w.Write([]byte(`{"file":{"file_id":9001,"download_url":"https://cdn.example/out.mp4"},"base_resp":{"status_code":0}}`))
default:
t.Errorf("unexpected upstream path: %s", r.URL.Path)
}
}))
defer upstream.Close()
a := &Adaptor{}
taskData, _ := json.Marshal(map[string]any{"task_id": "v1-up-1", "use_proxy": false, "model": "MiniMax-Hailuo-2.3"})
resp, err := a.FetchTask(upstream.URL, "sk-test", taskData)
if err != nil {
t.Fatalf("fetch: %v", err)
}
defer resp.Body.Close()
if queryPath != "/v1/query/video_generation?task_id=v1-up-1" {
t.Fatalf("v1 query path: %s", queryPath)
}
if filePath != "/v1/files/retrieve?file_id=9001" {
t.Fatalf("file retrieve path: %s", filePath)
}
// 合并后的响应体经 ParseTaskResult 提取 ResultURL
body, _ := io.ReadAll(resp.Body)
info, err := a.ParseTaskResult(body)
if err != nil {
t.Fatalf("parse merged: %v", err)
}
if info.Status != common.TaskStatusSuccess || info.ResultURL != "https://cdn.example/out.mp4" {
t.Fatalf("two-hop result: %+v", info)
}
}
// TestFetchTask_V1_TwoHopFailure 二跳失败:任务仍按 Success 返回,仅缺 download_url
func TestFetchTask_V1_TwoHopFailure(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/v1/query/video_generation") {
_, _ = w.Write([]byte(`{"task_id":"v1-up-1","status":"Success","file_id":"9001","base_resp":{"status_code":0}}`))
return
}
// files/retrieve 返回业务错误
_, _ = w.Write([]byte(`{"file":{"file_id":9001},"base_resp":{"status_code":1001,"status_msg":"request timeout"}}`))
}))
defer upstream.Close()
a := &Adaptor{}
taskData, _ := json.Marshal(map[string]any{"task_id": "v1-up-1", "use_proxy": false, "model": "MiniMax-Hailuo-2.3"})
resp, err := a.FetchTask(upstream.URL, "sk-test", taskData)
if err != nil {
t.Fatalf("fetch: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
info, err := a.ParseTaskResult(body)
if err != nil {
t.Fatalf("parse: %v", err)
}
if info.Status != common.TaskStatusSuccess {
t.Fatalf("two-hop failure must not change Success status: %+v", info)
}
if info.ResultURL != "" {
t.Fatalf("download_url must be absent on two-hop failure: %q", info.ResultURL)
}
}
func TestParseTaskResult_V1Statuses(t *testing.T) {
a := &Adaptor{}
cases := map[string]struct {
status common.TaskStatusEnum
reason string
}{
`{"task_id":"t","status":"Preparing","base_resp":{"status_code":0}}`: {common.TaskStatusQueued, ""},
`{"task_id":"t","status":"Queueing","base_resp":{"status_code":0}}`: {common.TaskStatusQueued, ""},
`{"task_id":"t","status":"Processing","base_resp":{"status_code":0}}`: {common.TaskStatusInProgress, ""},
`{"task_id":"t","status":"Fail","base_resp":{"status_code":1026,"status_msg":"content violates policy"}}`: {common.TaskStatusFailure, "content violates policy"},
}
for body, want := range cases {
info, err := a.ParseTaskResult([]byte(body))
if err != nil {
t.Fatalf("parse %s: %v", body, err)
}
if info.Status != want.status {
t.Errorf("%s: status=%v want %v", body, info.Status, want.status)
}
if want.reason != "" && info.FailReason != want.reason {
t.Errorf("%s: failReason=%q want %q", body, info.FailReason, want.reason)
}
}
}
+66
View File
@@ -0,0 +1,66 @@
package minimax
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/qianfree/team-api/relay/common"
)
// 上游任务取消(DELETE /v2/video_generation/{task_id})。
// 官方语义:仅 queued 态可取消(action=cancelled);running / cancelled 不可操作;
// succeeded / failed 是删除任务记录(action=deleted)——网关只做取消、不做删除,
// 因此调用方必须校验返回的 action 字段,仅 cancelled 视为取消成功。
// CancelResult 上游取消响应(DeleteVideoGenerationV2Resp)
type CancelResult struct {
TaskID string `json:"task_id"`
Action string `json:"action"` // cancelled = 取消成功;deleted = 任务已终态被删记录(非取消)
Status string `json:"status"`
}
// CancelTask 取消上游排队中的任务。
// 返回的 TaskError 携带上游状态码与错误信息(含上游判定「运行中不可取消」等场景),
// 调用方以本结果为准决定是否向客户端确认取消。
func CancelTask(baseURL, apiKey, taskID string, useProxy bool) (*CancelResult, *common.TaskError) {
url := fmt.Sprintf("%s/v2/video_generation/%s", strings.TrimRight(baseURL, "/"), taskID)
req, err := http.NewRequest(http.MethodDelete, url, nil)
if err != nil {
return nil, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "create cancel request failed"}
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
client := common.NewPooledClient(30, useProxy)
resp, err := client.Do(req)
if err != nil {
return nil, &common.TaskError{StatusCode: http.StatusBadGateway, Message: "cancel request failed: " + err.Error()}
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "read cancel response failed"}
}
if resp.StatusCode != http.StatusOK {
msg := strings.TrimSpace(string(body))
var errResp oaiErrorBody
if json.Unmarshal(body, &errResp) == nil && errResp.Error.Message != "" {
msg = errResp.Error.Message
}
if msg == "" {
msg = fmt.Sprintf("upstream returned status %d", resp.StatusCode)
}
return nil, &common.TaskError{StatusCode: resp.StatusCode, Message: msg}
}
var result CancelResult
if err := json.Unmarshal(body, &result); err != nil {
return nil, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "parse cancel response failed"}
}
return &result, nil
}
+21
View File
@@ -0,0 +1,21 @@
package minimax
// MiniMax 视频生成模型:H3 系列走 v2 协议(content[] 多模态),Hailuo 2.x 主力型号走 v1 扁平协议。
// 旧型号(T2V/I2V/S2V-01 系列)不进默认清单,但请求传入照样转发(协议分派按模型名,不拦)。
var ModelList = []string{
"MiniMax-H3",
"MiniMax-H3-Max",
"MiniMax-Hailuo-2.3",
"MiniMax-Hailuo-2.3-Fast",
"MiniMax-Hailuo-02",
}
const channelName = "MiniMaxVideo"
// 分辨率/时长缺省值(与计费估算共用,见 adaptor.go 的 resolveResolution/resolveDuration)
const (
defaultResolution = "768P" // H3 与 Hailuo 2.x 的默认档
defaultResolutionV1 = "720P" // T2V/I2V/S2V 等旧型号的默认档
defaultDurationSecs = 6
defaultTextOnlyRatio = "16:9"
)
+51
View File
@@ -0,0 +1,51 @@
package minimax
// MiniMax v2 视频协议的任务对象与响应结构。
// 字段定义对齐官方 VideoTask(docs/modeldocs/minimax/video/04-响应对象.md),
// 由适配器解析上游查询响应、relay/handler 层组装查询端点回放响应共用。
// VideoTaskContent 任务产物内容(视频任务返回 url,Context-IR 任务返回 prompt)
type VideoTaskContent struct {
URL string `json:"url,omitempty"`
Prompt string `json:"prompt,omitempty"`
}
// VideoTaskError 任务错误信息(任务失败时返回)
type VideoTaskError struct {
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
}
// VideoTaskUsage 用量:视频任务返回按秒计量字段,H3-Context-IR 任务返回 Token 字段
type VideoTaskUsage 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"`
InputAudioSeconds int `json:"input_audio_seconds,omitempty"`
TotalTokens int `json:"total_tokens,omitempty"`
PromptTokens int `json:"prompt_tokens,omitempty"`
CompletionTokens int `json:"completion_tokens,omitempty"`
}
// VideoTask 官方 VideoTask 对象(单任务查询与列表接口共用的任务载体)
type VideoTask struct {
ID string `json:"id"`
Model string `json:"model"`
Status string `json:"status"`
Error *VideoTaskError `json:"error,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
Content *VideoTaskContent `json:"content,omitempty"`
Resolution string `json:"resolution,omitempty"`
Duration int `json:"duration,omitempty"`
Usage *VideoTaskUsage `json:"usage,omitempty"`
Ratio string `json:"ratio,omitempty"`
TaskType string `json:"task_type,omitempty"`
Modality string `json:"modality,omitempty"`
}
// TaskQueryResponse v2 单任务查询响应(GetVideoGenerationV2Resp,task 外层包装)
type TaskQueryResponse struct {
Task *VideoTask `json:"task"`
}
+61
View File
@@ -0,0 +1,61 @@
// 模型研发厂商共享常量(管理后台模型管理列表/筛选/表单共用)
// value 与后端 mdl_models.vendor 枚举一一对应(api/admin/v1/model.go 的 v:"in:" 校验、
// internal/logic/admin/model.go 的 validModelVendors)。
// 新增厂商时需同步:后端枚举校验、租户端 web/tenant/src/constants/vendor.ts、本文件。
// 注意与渠道供应商类型(constants/channel.ts,chn_channels.type)是两个维度:厂商 = 模型的研发公司,
// 例如 Bedrock 上的 Claude 厂商仍是 anthropic。
export const vendorOptions: { label: string; value: string }[] = [
{ label: 'OpenAI', value: 'openai' },
{ label: 'Anthropic', value: 'anthropic' },
{ label: 'Google', value: 'google' },
{ label: 'xAI', value: 'xai' },
{ label: 'Mistral', value: 'mistral' },
{ label: 'Cohere', value: 'cohere' },
{ label: 'Meta', value: 'meta' },
{ label: '阿里巴巴', value: 'alibaba' },
{ label: '字节跳动', value: 'bytedance' },
{ label: '深度求索', value: 'deepseek' },
{ label: '智谱AI', value: 'zhipu' },
{ label: '月之暗面', value: 'moonshot' },
{ label: 'MiniMax', value: 'minimax' },
{ label: '百度', value: 'baidu' },
{ label: '腾讯', value: 'tencent' },
{ label: '科大讯飞', value: 'xunfei' },
{ label: '快手', value: 'kuaishou' },
{ label: 'Midjourney', value: 'midjourney' },
{ label: 'Suno', value: 'suno' },
]
// value -> label 映射,表格列展示用
export const vendorLabelMap: Record<string, string> = {}
vendorOptions.forEach(o => { vendorLabelMap[o.value] = o.label })
// value -> Arco Tag 预设色映射(淡底深字,取接近品牌色的预设)
export const vendorTagColor: Record<string, string> = {
openai: 'green',
anthropic: 'orange',
google: 'arcoblue',
xai: 'gray',
mistral: 'orangered',
cohere: 'cyan',
meta: 'blue',
alibaba: 'purple', // 图标用通义千问 mark(品牌紫),Tag 色随之取紫
bytedance: 'arcoblue',
deepseek: 'blue',
zhipu: 'purple',
moonshot: 'gray',
minimax: 'red',
baidu: 'blue',
tencent: 'cyan',
xunfei: 'arcoblue',
kuaishou: 'orangered',
midjourney: 'gray',
suno: 'purple',
}
// Arco Select 的 allow-search 自定义过滤:小写包含匹配
export function filterVendorOption(inputValue: string, option: { label: string; value: string }) {
const input = inputValue.toLowerCase()
return option.label.toLowerCase().includes(input)
}
+19 -4
View File
@@ -162,6 +162,7 @@ const pricingModeTagColor: Record<string, string> = {
token: 'arcoblue',
per_request: 'orangered',
tiered: 'purple',
per_second: 'green',
}
// ===== 移动端卡片自定义布局用的格式化(仅用于卡片插槽,不改动桌面端列 render)=====
@@ -177,18 +178,29 @@ function formatTokens(n: number | null | undefined): string {
return String(n)
}
// 定价主文本:本位币 输入/输出、单价/次、未定价(bil 层定价直显)
// 按秒模式基准单价:优先 "*" 兜底价,否则取矩阵最小正价(与定价弹窗 perSecondBasePrice 口径一致)
function perSecondBasePrice(row: any): number {
const prices = Object.values(row.per_second_prices || {}).map((p) => Number(p) || 0).filter((p) => p > 0)
if (prices.length === 0) return 0
const wildcard = Number(row.per_second_prices?.['*']) || 0
return wildcard > 0 ? wildcard : Math.min(...prices)
}
// 定价主文本:本位币 输入/输出、单价/次、单价/秒、未定价(bil 层定价直显)
function pricingMain(row: any): string {
if (!row.pricing_mode) return '未定价'
if (row.pricing_mode === 'per_request') {
return `${formatBilling(row.per_request_price ?? 0, 4)}/次`
}
if (row.pricing_mode === 'per_second') {
return `${formatBilling(perSecondBasePrice(row), 4)}/秒`
}
return `${formatBilling(row.input_price ?? 0, 2)}/${formatBilling(row.output_price ?? 0, 2)}`
}
// 定价模式副文本:按量 / 按次 / 阶梯(未定价返回空串)
// 定价模式副文本:按量 / 按次 / 阶梯 / 按秒(未定价返回空串)
function pricingMode(row: any): string {
const m: Record<string, string> = { token: '按量', per_request: '按次', tiered: '阶梯' }
const m: Record<string, string> = { token: '按量', per_request: '按次', tiered: '阶梯', per_second: '按秒' }
return row.pricing_mode ? (m[row.pricing_mode] || row.pricing_mode) : ''
}
@@ -237,13 +249,16 @@ const columns: TableColumnData[] = [
if (!record.pricing_mode) {
return h(Tag, { color: 'orangered', size: 'small' }, () => '未定价')
}
const modeLabel: Record<string, string> = { token: '按量', per_request: '按次', tiered: '阶梯' }
const modeLabel: Record<string, string> = { token: '按量', per_request: '按次', tiered: '阶梯', per_second: '按秒' }
const tags = [
h(Tag, { color: pricingModeTagColor[record.pricing_mode] || 'gray', size: 'small' }, () => modeLabel[record.pricing_mode] || record.pricing_mode),
]
if (record.pricing_mode === 'per_request') {
tags.push(h('span', { style: 'font-size: 12px; color: var(--color-text-3); margin-left: 4px;' },
`${formatBilling(record.per_request_price ?? 0, 4)}/次`))
} else if (record.pricing_mode === 'per_second') {
tags.push(h('span', { style: 'font-size: 12px; color: var(--color-text-3); margin-left: 4px;' },
`${formatBilling(perSecondBasePrice(record), 4)}/秒`))
} else {
tags.push(h('span', { style: 'font-size: 12px; color: var(--color-text-3); margin-left: 4px;' },
`${formatBilling(record.input_price ?? 0, 2)}/${formatBilling(record.output_price ?? 0, 2)}`))
+2
View File
@@ -49,6 +49,7 @@ const platformOptions = [
{ label: 'Suno', value: 'suno' },
{ label: '火山引擎', value: 'volcengine' },
{ label: '阿里', value: 'ali' },
{ label: 'MiniMax', value: 'minimax' },
]
const statusTagColor: Record<string, string | undefined> = {
@@ -74,6 +75,7 @@ const platformLabel: Record<string, string> = {
suno: 'Suno',
volcengine: '火山引擎',
ali: '阿里',
minimax: 'MiniMax',
}
const columns: TableColumnData[] = [
+49
View File
@@ -0,0 +1,49 @@
// 厂商元数据:与后端 mdl_models.vendor 枚举对齐(api/admin/v1/model.go、api/tenant/v1/marketplace.go
// 的 v:"in:" 列表,以及 internal/logic/admin/model.go 的 validModelVendors)。
// 新增厂商时需同步:后端枚举校验、本文件、web/admin/src/constants/vendor.ts,并补 src/assets/vendors/{slug}.svg。
// 注意与渠道供应商(chn_channels.type)是两个维度:厂商 = 模型的研发公司。
export interface VendorMeta {
// 枚举值(入库值)
value: string
// 显示名
label: string
// 品牌色(logo 芯片底色)
color: string
// logo 本身是多色图形(如 cohere 三色 mark),芯片用浅色底展示彩色 logo
colored?: boolean
}
export const vendorMetaList: VendorMeta[] = [
{ value: 'openai', label: 'OpenAI', color: '#10a37f' },
{ value: 'anthropic', label: 'Anthropic', color: '#d97757' },
{ value: 'google', label: 'Google', color: '#4285f4' },
{ value: 'xai', label: 'xAI', color: '#000000' },
{ value: 'mistral', label: 'Mistral', color: '#fa520f' },
{ value: 'cohere', label: 'Cohere', color: '#39594d', colored: true },
{ value: 'meta', label: 'Meta', color: '#0866ff' },
{ value: 'alibaba', label: '阿里巴巴', color: '#615ced' }, // 图标用通义千问 mark,底色取千问品牌紫
{ value: 'bytedance', label: '字节跳动', color: '#325ab4' },
{ value: 'deepseek', label: '深度求索', color: '#4d6bfe' },
{ value: 'zhipu', label: '智谱AI', color: '#3e5cfa' },
{ value: 'moonshot', label: '月之暗面', color: '#16191e' },
{ value: 'minimax', label: 'MiniMax', color: '#1f1f1f' },
{ value: 'baidu', label: '百度', color: '#2932e1' },
{ value: 'tencent', label: '腾讯', color: '#0052d9' },
{ value: 'xunfei', label: '科大讯飞', color: '#0f4cfd' },
{ value: 'kuaishou', label: '快手', color: '#ff5000' },
{ value: 'midjourney', label: 'Midjourney', color: '#111111' },
{ value: 'suno', label: 'Suno', color: '#111111' },
]
// 未收录厂商(vendor 非空但字典缺失)与未设置厂商(空串/null)的展示兜底
export function getVendorMeta(vendor?: string | null): VendorMeta | null {
if (!vendor) return null
return vendorMetaList.find(item => item.value === vendor) ?? null
}
// 厂商显示名:字典命中取 label,未收录回退原值,未设置返回「未分类」
export function vendorLabel(vendor?: string | null): string {
if (!vendor) return '未分类'
return getVendorMeta(vendor)?.label ?? vendor
}
@@ -109,6 +109,7 @@ const platformLabel: Record<string, string> = {
suno: 'Suno',
volcengine: '火山引擎',
ali: '阿里',
minimax: 'MiniMax',
}
// 金额格式化统一走本位币(formatBilling 内部读取响应式 displayCurrency,配置变化自动重渲染)
@@ -289,7 +290,7 @@ onMounted(() => {
</div>
<div class="flex items-center gap-2">
<label class="text-sm text-gray-500 whitespace-nowrap">平台</label>
<BaseSelect v-model="filterPlatform" :options="[{value:'',label:'全部'},{value:'sora',label:'Sora'},{value:'kling',label:'Kling'},{value:'midjourney',label:'Midjourney'},{value:'suno',label:'Suno'},{value:'volcengine',label:'火山引擎'},{value:'ali',label:'阿里'}]" container-class="w-[120px]" />
<BaseSelect v-model="filterPlatform" :options="[{value:'',label:'全部'},{value:'sora',label:'Sora'},{value:'kling',label:'Kling'},{value:'midjourney',label:'Midjourney'},{value:'suno',label:'Suno'},{value:'volcengine',label:'火山引擎'},{value:'ali',label:'阿里'},{value:'minimax',label:'MiniMax'}]" container-class="w-[120px]" />
</div>
<div class="ml-auto flex items-center gap-2">
<button type="submit" class="btn btn-primary btn-sm">
@@ -31,7 +31,9 @@ const duration = ref(5)
const modelOptions = computed(() => props.models.map(m => ({ value: m.model_id, label: m.model_name || m.model_id })))
const resolutionPresets = [
// MiniMax-H3 系列使用官方分辨率档位(768P/2K,Max 追加 480P;与后端计费矩阵键一致),
// 其他模型维持像素串词汇——档位词汇不能混入其他厂商(会产生非法 resolution)
const pixelResolutionPresets = [
{ value: '854x480', label: '480p 横' },
{ value: '480x854', label: '480p 竖' },
{ value: '1280x720', label: '720p 横' },
@@ -40,37 +42,58 @@ const resolutionPresets = [
{ value: '1080x1920', label: '1080p 竖' },
]
const isMiniMaxH3 = computed(() => selectedModel.value.toLowerCase().startsWith('minimax-h3'))
const resolutionPresets = computed(() => {
if (isMiniMaxH3.value) {
const presets = [
{ value: '768P', label: '768P' },
{ value: '2K', label: '2K' },
]
if (selectedModel.value.toLowerCase().startsWith('minimax-h3-max')) {
presets.unshift({ value: '480P', label: '480P' })
}
return presets
}
return pixelResolutionPresets
})
// 模型切换后当前分辨率不在该模型预设内时重置(避免像素串打到 MiniMax 或档位词汇打到其他厂商)
watch(resolutionPresets, presets => {
if (!presets.some(p => p.value === resolution.value)) {
resolution.value = presets[0]?.value || ''
}
})
interface TaskInfo {
id: string
// OpenAI Videos 协议状态机:queued / in_progress / completed / failed
status: string
progress: string
model: string
url?: string
error?: string
createdAt: number
completedAt?: number
}
const currentTask = ref<TaskInfo | null>(null)
const polling = ref(false)
// 成品视频地址:/v1/videos/{id}/content 需带 API Key 拉流,取回后转 blob URL 供播放/下载
const videoUrl = ref('')
// 任务状态轮询:页面隐藏时暂停(任务在服务端继续执行),恢复可见时立即补拉
const taskPoller = createPoller(pollLoop, 3000, { immediate: true })
const statusLabel: Record<string, string> = {
SUBMITTED: '已提交',
IN_PROGRESS: '生成中',
NOT_START: '排队中',
QUEUED: '排队中',
SUCCESS: '已完成',
FAILURE: '失败',
queued: '排队中',
in_progress: '生成中',
completed: '已完成',
failed: '失败',
}
const statusColor: Record<string, string> = {
SUBMITTED: 'badge-primary',
IN_PROGRESS: 'badge-warning',
NOT_START: 'badge-gray',
QUEUED: 'badge-gray',
SUCCESS: 'badge-success',
FAILURE: 'badge-danger',
queued: 'badge-gray',
in_progress: 'badge-warning',
completed: 'badge-success',
failed: 'badge-danger',
}
function applyResolutionPreset(val: string) {
@@ -82,43 +105,43 @@ async function submitTask() {
submitting.value = true
currentTask.value = null
stopPolling()
releaseVideo()
try {
const api = createPlaygroundApi(props.apiKey)
const metadata: Record<string, any> = {}
if (resolution.value) {
metadata.resolution = resolution.value
}
if (duration.value) {
metadata.duration = duration.value
}
// OpenAI Videos 协议:size 承载分辨率(像素串或命名档位如 768P/2K),seconds 承载时长
const body: Record<string, any> = {
model: selectedModel.value,
prompt: prompt.value,
}
if (Object.keys(metadata).length > 0) {
body.metadata = metadata
if (resolution.value) {
body.size = resolution.value
}
if (duration.value) {
body.seconds = String(duration.value)
}
const res = await api.post('/v1/video/generations', body, { timeout: 60_000 })
const res = await api.post('/v1/videos', body, { timeout: 60_000 })
const data = res.data
currentTask.value = {
id: data.id,
status: data.status || 'SUBMITTED',
status: data.status || 'queued',
progress: '',
model: data.model || selectedModel.value,
createdAt: data.created_at || Math.floor(Date.now() / 1000),
}
// 开始轮询
if (data.status !== 'SUCCESS' && data.status !== 'FAILURE') {
if (data.status !== 'completed' && data.status !== 'failed') {
startPolling()
} else if (data.status === 'completed') {
loadVideoContent()
}
} catch (e: any) {
currentTask.value = {
id: '',
status: 'FAILURE',
status: 'failed',
progress: '',
model: selectedModel.value,
error: e?.message || '提交失败',
@@ -144,20 +167,23 @@ async function pollLoop() {
try {
const api = createPlaygroundApi(props.apiKey)
const res = await api.get(`/v1/video/generations/${currentTask.value.id}`)
const res = await api.get(`/v1/videos/${currentTask.value.id}`)
const data = res.data
currentTask.value = {
...currentTask.value,
status: data.status,
progress: data.progress || '',
url: data.url || undefined,
error: data.error || undefined,
// OpenAI Videos 的 progress 是 0-100 整数,归一为百分比字符串供模板直用
progress: data.progress != null ? `${data.progress}%` : currentTask.value.progress,
error: data.error?.message || undefined,
completedAt: data.completed_at || undefined,
}
if (data.status === 'SUCCESS' || data.status === 'FAILURE') {
if (data.status === 'completed' || data.status === 'failed') {
stopPolling()
if (data.status === 'completed') {
loadVideoContent()
}
return
}
} catch {
@@ -165,21 +191,54 @@ async function pollLoop() {
}
}
// 组件卸载时停止轮询,避免路由离开后仍持续请求任务状态
// loadVideoContent 拉取成品视频:content 端点要求 API Key 鉴权,浏览器无法直接用 URL 播放,
// 取回二进制后转 blob URL 供 <video> 播放与下载
async function loadVideoContent() {
if (!currentTask.value?.id || videoUrl.value) return
try {
const api = createPlaygroundApi(props.apiKey)
const res = await api.get(`/v1/videos/${currentTask.value.id}/content`, {
responseType: 'blob',
timeout: 300_000,
})
videoUrl.value = URL.createObjectURL(res.data)
} catch {
// 拉取失败不改变任务状态,下载按钮可重试(downloadVideo 内再次触发加载)
currentTask.value = {
...currentTask.value,
error: currentTask.value?.error || '视频加载失败,请点击下载重试',
}
}
}
// releaseVideo 释放 blob URL,避免内存泄漏
function releaseVideo() {
if (videoUrl.value) {
URL.revokeObjectURL(videoUrl.value)
videoUrl.value = ''
}
}
// 组件卸载时停止轮询并释放资源,避免路由离开后仍持续请求任务状态
onUnmounted(() => {
stopPolling()
releaseVideo()
})
function resetTask() {
stopPolling()
releaseVideo()
currentTask.value = null
}
function downloadVideo() {
if (!currentTask.value?.url) return
if (!videoUrl.value) {
loadVideoContent()
return
}
const a = document.createElement('a')
a.href = currentTask.value.url
a.download = `video_${currentTask.value.id || 'output'}.mp4`
a.href = videoUrl.value
a.download = `video_${currentTask.value?.id || 'output'}.mp4`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
@@ -221,7 +280,8 @@ function downloadVideo() {
</div>
<div>
<label class="input-label">时长({{ duration }} 秒)</label>
<n-input-number v-model:value="duration" :min="5" :max="15" :step="1" class="w-full" />
<!-- min=4 覆盖 MiniMax-H3 的 4 秒档;其他模型档位由上游校验 -->
<n-input-number v-model:value="duration" :min="4" :max="15" :step="1" class="w-full" />
</div>
<button class="btn btn-primary w-full" :disabled="submitting || polling || !prompt.trim()" @click="submitTask">
{{ submitting ? '提交中...' : polling ? '生成中...' : '生成视频' }}
@@ -266,7 +326,7 @@ function downloadVideo() {
</div>
<!-- 进度条 -->
<div v-if="currentTask.status === 'IN_PROGRESS' || currentTask.status === 'SUBMITTED'" class="w-full bg-gray-100 rounded-full h-2 overflow-hidden">
<div v-if="currentTask.status === 'in_progress' || currentTask.status === 'queued'" class="w-full bg-gray-100 rounded-full h-2 overflow-hidden">
<div
class="h-full rounded-full bg-primary-500 transition-all duration-500"
:style="{ width: currentTask.progress || '10%' }"
@@ -274,16 +334,21 @@ function downloadVideo() {
</div>
<!-- 视频结果 -->
<div v-if="currentTask.status === 'SUCCESS' && currentTask.url" class="space-y-3">
<video controls class="w-full" :src="currentTask.url" />
<div v-if="currentTask.status === 'completed' && videoUrl" class="space-y-3">
<video controls class="w-full" :src="videoUrl" />
<button class="btn btn-secondary btn-sm" @click="downloadVideo">
<Icon name="arrowDown" size="sm" />
下载视频
</button>
</div>
<!-- 成品加载中(content 端点拉流转 blob) -->
<div v-else-if="currentTask.status === 'completed'" class="flex items-center justify-center py-10">
<div class="spinner h-6 w-6 text-primary-600"></div>
<span class="ml-3 text-sm text-gray-500">视频加载中...</span>
</div>
<!-- 错误信息 -->
<div v-if="currentTask.status === 'FAILURE'" class="rounded-xl border border-red-200 bg-red-50 p-4">
<div v-if="currentTask.status === 'failed'" class="rounded-xl border border-red-200 bg-red-50 p-4">
<div class="flex items-center gap-2 text-red-700">
<Icon name="xCircle" size="sm" />
<span class="text-sm font-medium">生成失败</span>