From 5ce16daa46f736686aba59c7b2d334ecd61cecf4 Mon Sep 17 00:00:00 2001 From: qian Date: Sun, 13 Sep 2026 09:24:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(model):=E6=96=B0=E5=A2=9EMiniMax-H3?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reference/api-format-reference.md | 55 +- internal/cmd/cmd.go | 16 + internal/handler/relay/minimax_video.go | 224 +++++ internal/logic/task/async_polling.go | 8 +- internal/logic/tenant/task.go | 3 + relay/channel/ali/converter_test.go | 4 +- relay/channel/volcengine/convert_test.go | 8 +- relay/constant/relay_mode.go | 3 + relay/constant/task.go | 3 + relay/handler/minimax_video.go | 329 ++++++++ relay/handler/minimax_video_convert.go | 356 ++++++++ relay/handler/minimax_video_convert_test.go | 147 ++++ relay/handler/minimax_video_pipeline_test.go | 332 ++++++++ relay/handler/minimax_video_test.go | 427 ++++++++++ relay/handler/task_handler.go | 12 +- relay/handler/videos_convert.go | 27 +- relay/handler/videos_convert_test.go | 24 +- relay/handler/videos_pipeline_test.go | 16 +- relay/taskchannel/minimax/adaptor.go | 787 ++++++++++++++++++ relay/taskchannel/minimax/adaptor_test.go | 630 ++++++++++++++ relay/taskchannel/minimax/cancel.go | 66 ++ relay/taskchannel/minimax/constants.go | 21 + relay/taskchannel/minimax/types.go | 51 ++ web/admin/src/constants/vendor.ts | 61 ++ web/admin/src/views/ai/ModelsPage.vue | 23 +- web/admin/src/views/ai/TaskLogsPage.vue | 2 + web/tenant/src/constants/vendor.ts | 49 ++ web/tenant/src/views/service/TaskLogsPage.vue | 3 +- .../src/views/service/playground/VideoTab.vue | 147 +++- 29 files changed, 3736 insertions(+), 98 deletions(-) create mode 100644 internal/handler/relay/minimax_video.go create mode 100644 relay/handler/minimax_video.go create mode 100644 relay/handler/minimax_video_convert.go create mode 100644 relay/handler/minimax_video_convert_test.go create mode 100644 relay/handler/minimax_video_pipeline_test.go create mode 100644 relay/handler/minimax_video_test.go create mode 100644 relay/taskchannel/minimax/adaptor.go create mode 100644 relay/taskchannel/minimax/adaptor_test.go create mode 100644 relay/taskchannel/minimax/cancel.go create mode 100644 relay/taskchannel/minimax/constants.go create mode 100644 relay/taskchannel/minimax/types.go create mode 100644 web/admin/src/constants/vendor.ts create mode 100644 web/tenant/src/constants/vendor.ts diff --git a/docs/reference/api-format-reference.md b/docs/reference/api-format-reference.md index 72e2d4a..28e42d2 100644 --- a/docs/reference/api-format-reference.md +++ b/docs/reference/api-format-reference.md @@ -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 格式端点响应 diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 23d5276..be79414 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -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 构建)时,为后端根路径注册项目介绍页, diff --git a/internal/handler/relay/minimax_video.go b/internal/handler/relay/minimax_video.go new file mode 100644 index 0000000..2176856 --- /dev/null +++ b/internal/handler/relay/minimax_video.go @@ -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) +} diff --git a/internal/logic/task/async_polling.go b/internal/logic/task/async_polling.go index 6f04c39..7cf8913 100644 --- a/internal/logic/task/async_polling.go +++ b/internal/logic/task/async_polling.go @@ -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) diff --git a/internal/logic/tenant/task.go b/internal/logic/tenant/task.go index 2bc7a68..300d8ba 100644 --- a/internal/logic/tenant/task.go +++ b/internal/logic/tenant/task.go @@ -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_ 记账层,币种 = 系统本位币 diff --git a/relay/channel/ali/converter_test.go b/relay/channel/ali/converter_test.go index 5d835d5..5283cfa 100644 --- a/relay/channel/ali/converter_test.go +++ b/relay/channel/ali/converter_test.go @@ -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) diff --git a/relay/channel/volcengine/convert_test.go b/relay/channel/volcengine/convert_test.go index 337e921..e5de359 100644 --- a/relay/channel/volcengine/convert_test.go +++ b/relay/channel/volcengine/convert_test.go @@ -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}, diff --git a/relay/constant/relay_mode.go b/relay/constant/relay_mode.go index 0a8493e..3c75711 100644 --- a/relay/constant/relay_mode.go +++ b/relay/constant/relay_mode.go @@ -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: diff --git a/relay/constant/task.go b/relay/constant/task.go index ebea0cf..afbf577 100644 --- a/relay/constant/task.go +++ b/relay/constant/task.go @@ -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 } diff --git a/relay/handler/minimax_video.go b/relay/handler/minimax_video.go new file mode 100644 index 0000000..8191ddb --- /dev/null +++ b/relay/handler/minimax_video.go @@ -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) +} diff --git a/relay/handler/minimax_video_convert.go b/relay/handler/minimax_video_convert.go new file mode 100644 index 0000000..fb45845 --- /dev/null +++ b/relay/handler/minimax_video_convert.go @@ -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, + } +} diff --git a/relay/handler/minimax_video_convert_test.go b/relay/handler/minimax_video_convert_test.go new file mode 100644 index 0000000..ff3ee50 --- /dev/null +++ b/relay/handler/minimax_video_convert_test.go @@ -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) + } +} diff --git a/relay/handler/minimax_video_pipeline_test.go b/relay/handler/minimax_video_pipeline_test.go new file mode 100644 index 0000000..bfa8d03 --- /dev/null +++ b/relay/handler/minimax_video_pipeline_test.go @@ -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[]") + } +} diff --git a/relay/handler/minimax_video_test.go b/relay/handler/minimax_video_test.go new file mode 100644 index 0000000..f6eb679 --- /dev/null +++ b/relay/handler/minimax_video_test.go @@ -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()) + } +} diff --git a/relay/handler/task_handler.go b/relay/handler/task_handler.go index efcac39..af9ada6 100644 --- a/relay/handler/task_handler.go +++ b/relay/handler/task_handler.go @@ -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", diff --git a/relay/handler/videos_convert.go b/relay/handler/videos_convert.go index 5200f83..96f5747 100644 --- a/relay/handler/videos_convert.go +++ b/relay/handler/videos_convert.go @@ -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 diff --git a/relay/handler/videos_convert_test.go b/relay/handler/videos_convert_test.go index 956abe1..58fcbba 100644 --- a/relay/handler/videos_convert_test.go +++ b/relay/handler/videos_convert_test.go @@ -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"]) } diff --git a/relay/handler/videos_pipeline_test.go b/relay/handler/videos_pipeline_test.go index e126008..998356a 100644 --- a/relay/handler/videos_pipeline_test.go +++ b/relay/handler/videos_pipeline_test.go @@ -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 { diff --git a/relay/taskchannel/minimax/adaptor.go b/relay/taskchannel/minimax/adaptor.go new file mode 100644 index 0000000..06c2b37 --- /dev/null +++ b/relay/taskchannel/minimax/adaptor.go @@ -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 "" +} diff --git a/relay/taskchannel/minimax/adaptor_test.go b/relay/taskchannel/minimax/adaptor_test.go new file mode 100644 index 0000000..74ff215 --- /dev/null +++ b/relay/taskchannel/minimax/adaptor_test.go @@ -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) + } + } +} diff --git a/relay/taskchannel/minimax/cancel.go b/relay/taskchannel/minimax/cancel.go new file mode 100644 index 0000000..6f8c6a8 --- /dev/null +++ b/relay/taskchannel/minimax/cancel.go @@ -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 +} diff --git a/relay/taskchannel/minimax/constants.go b/relay/taskchannel/minimax/constants.go new file mode 100644 index 0000000..da6bab4 --- /dev/null +++ b/relay/taskchannel/minimax/constants.go @@ -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" +) diff --git a/relay/taskchannel/minimax/types.go b/relay/taskchannel/minimax/types.go new file mode 100644 index 0000000..28c95a9 --- /dev/null +++ b/relay/taskchannel/minimax/types.go @@ -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"` +} diff --git a/web/admin/src/constants/vendor.ts b/web/admin/src/constants/vendor.ts new file mode 100644 index 0000000..2c8fcac --- /dev/null +++ b/web/admin/src/constants/vendor.ts @@ -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 = {} +vendorOptions.forEach(o => { vendorLabelMap[o.value] = o.label }) + +// value -> Arco Tag 预设色映射(淡底深字,取接近品牌色的预设) +export const vendorTagColor: Record = { + 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) +} diff --git a/web/admin/src/views/ai/ModelsPage.vue b/web/admin/src/views/ai/ModelsPage.vue index ce845a1..b0e0e55 100644 --- a/web/admin/src/views/ai/ModelsPage.vue +++ b/web/admin/src/views/ai/ModelsPage.vue @@ -162,6 +162,7 @@ const pricingModeTagColor: Record = { 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 = { token: '按量', per_request: '按次', tiered: '阶梯' } + const m: Record = { 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 = { token: '按量', per_request: '按次', tiered: '阶梯' } + const modeLabel: Record = { 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)}`)) diff --git a/web/admin/src/views/ai/TaskLogsPage.vue b/web/admin/src/views/ai/TaskLogsPage.vue index 2ddcdfd..9516c92 100644 --- a/web/admin/src/views/ai/TaskLogsPage.vue +++ b/web/admin/src/views/ai/TaskLogsPage.vue @@ -49,6 +49,7 @@ const platformOptions = [ { label: 'Suno', value: 'suno' }, { label: '火山引擎', value: 'volcengine' }, { label: '阿里', value: 'ali' }, + { label: 'MiniMax', value: 'minimax' }, ] const statusTagColor: Record = { @@ -74,6 +75,7 @@ const platformLabel: Record = { suno: 'Suno', volcengine: '火山引擎', ali: '阿里', + minimax: 'MiniMax', } const columns: TableColumnData[] = [ diff --git a/web/tenant/src/constants/vendor.ts b/web/tenant/src/constants/vendor.ts new file mode 100644 index 0000000..f20c5f4 --- /dev/null +++ b/web/tenant/src/constants/vendor.ts @@ -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 +} diff --git a/web/tenant/src/views/service/TaskLogsPage.vue b/web/tenant/src/views/service/TaskLogsPage.vue index 44a19c5..112747b 100644 --- a/web/tenant/src/views/service/TaskLogsPage.vue +++ b/web/tenant/src/views/service/TaskLogsPage.vue @@ -109,6 +109,7 @@ const platformLabel: Record = { suno: 'Suno', volcengine: '火山引擎', ali: '阿里', + minimax: 'MiniMax', } // 金额格式化统一走本位币(formatBilling 内部读取响应式 displayCurrency,配置变化自动重渲染) @@ -289,7 +290,7 @@ onMounted(() => {
- +
- + +
+ +
+
+ 视频加载中... +
-
+
生成失败