feat(model):新增MiniMax-H3视频支持
This commit is contained in:
1 parent
26c53c2228
commit
5ce16daa46
29 files changed
+3736
-98
No files matched your search
@@ -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 格式端点响应
|
||||
|
||||
|
||||
@@ -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 构建)时,为后端根路径注册项目介绍页,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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_ 记账层,币种 = 系统本位币
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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[]")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)}`))
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user