diff --git a/docs/plugin-api/v1.md b/docs/plugin-api/v1.md index 62fba917a1..3bdf00b998 100644 --- a/docs/plugin-api/v1.md +++ b/docs/plugin-api/v1.md @@ -139,4 +139,4 @@ Protocol media uses host-injected `ctx.artifacts[key].url`. Provider URLs from ` The persisted field remains `task.data`; there is no `task.raw` alias. Driver hooks (`buildSubmitRequest`, `parseSubmitResponse`, query/result, usage, artifact, and content hooks) stay flat and must not branch on the client path or protocol. -`ctx.model` is the billing and display identity (the origin name the client sent, including a channel-mapping alias). `ctx.upstreamModel` is the machine identity after channel `model_mapping`. Rate tables and model-keyed usage facts must use `ctx.upstreamModel || ctx.model`. Decode and render hooks that echo the client model must keep `ctx.model`. `buildSubmitRequest` must not set descriptor top-level `model` on a mapped pin; the host requires the plugin to echo the alias verbatim. +`ctx.model` is the billing and display identity (the origin name the client sent, including a channel-mapping alias). `ctx.upstreamModel` is the machine identity after channel `model_mapping`. Rate tables and model-keyed usage facts must use `ctx.upstreamModel || ctx.model`. Decode and render hooks that echo the client model must keep `ctx.model`. `buildSubmitRequest` must not set descriptor top-level `model` on a mapped pin; the host requires the plugin to echo the alias verbatim. Background polling has no relay info, so query hooks receive both identities from the persisted task properties, and `ctx.upstreamModel` falls back to `ctx.model` when the task was submitted without a channel mapping. diff --git a/plugins/hailuo_responses_test.go b/plugins/hailuo_responses_test.go index decc29a1d1..3bf8bf98a7 100644 --- a/plugins/hailuo_responses_test.go +++ b/plugins/hailuo_responses_test.go @@ -70,3 +70,412 @@ func TestHailuoArtifactContentProxy(t *testing.T) { assert.Equal(t, map[string]string{"Accept": "video/*", "Authorization": "Bearer test-ak"}, descriptor.Headers) assert.False(t, descriptor.Credentialless) } + +func loadHailuoPlugin(t *testing.T) *jsplugin.LoadedPlugin { + t.Helper() + source, err := builtinplugins.Source("hailuo") + require.NoError(t, err) + plugin, err := jsplugin.NewRegistry().RegisterFactory(source, jsplugin.Options{Key: "hailuo"}) + require.NoError(t, err) + return plugin +} + +func callHailuoHook(t *testing.T, plugin *jsplugin.LoadedPlugin, hook string, args ...any) map[string]any { + t.Helper() + value, err := plugin.Engine.Call(t.Context(), hook, args...) + require.NoError(t, err) + encoded, err := common.Marshal(value) + require.NoError(t, err) + var decoded map[string]any + require.NoError(t, common.Unmarshal(encoded, &decoded)) + return decoded +} + +func hailuoH3SubmitContext(requestBody map[string]any) map[string]any { + return map[string]any{ + "requestBody": requestBody, + "model": "MiniMax-H3", + "upstreamModel": "MiniMax-H3", + "baseUrl": "https://api.minimax.example", + "apiKey": "test-ak", + } +} + +// MiniMax-H3 submits to /v2/video_generation with a multimodal content array +// instead of the flat /v1 frame fields. +func TestHailuoH3BuildSubmitRequest(t *testing.T) { + plugin := loadHailuoPlugin(t) + testCases := []struct { + name string + request map[string]any + wantBody string + wantAction string + }{ + { + name: "text to video defaults duration ratio and resolution", + request: map[string]any{"prompt": "a boy playing basketball"}, + wantBody: `{"model":"MiniMax-H3","content":[{"type":"text","text":"a boy playing basketball"}],"resolution":"768P","duration":5,"ratio":"16:9"}`, + wantAction: "text_to_video", + }, + { + name: "2K resolution from size", + request: map[string]any{"prompt": "p", "duration": 15, "size": "2K"}, + wantBody: `{"model":"MiniMax-H3","content":[{"type":"text","text":"p"}],"resolution":"2K","duration":15,"ratio":"16:9"}`, + wantAction: "text_to_video", + }, + { + name: "first and last frame from metadata", + request: map[string]any{"prompt": "p", "metadata": map[string]any{"first_frame_image": "first.png", "last_frame_image": "last.png"}}, + wantBody: `{"model":"MiniMax-H3","content":[ + {"type":"text","text":"p"}, + {"type":"image_url","role":"first_frame","image_url":{"url":"first.png"}}, + {"type":"image_url","role":"last_frame","image_url":{"url":"last.png"}}], + "resolution":"768P","duration":5,"ratio":"adaptive"}`, + wantAction: "image_to_video", + }, + { + name: "frames from the images array", + request: map[string]any{"prompt": "p", "images": []any{"first.png", "last.png"}}, + wantBody: `{"model":"MiniMax-H3","content":[ + {"type":"text","text":"p"}, + {"type":"image_url","role":"first_frame","image_url":{"url":"first.png"}}, + {"type":"image_url","role":"last_frame","image_url":{"url":"last.png"}}], + "resolution":"768P","duration":5,"ratio":"adaptive"}`, + wantAction: "image_to_video", + }, + { + name: "reference video and audio", + request: map[string]any{"prompt": "p", "metadata": map[string]any{ + "reference_video": "ref.mp4", + "reference_audio": []any{"a.mp3", "b.mp3"}, + }}, + wantBody: `{"model":"MiniMax-H3","content":[ + {"type":"text","text":"p"}, + {"type":"video_url","role":"reference_video","video_url":{"url":"ref.mp4"}}, + {"type":"audio_url","role":"reference_audio","audio_url":{"url":"a.mp3"}}, + {"type":"audio_url","role":"reference_audio","audio_url":{"url":"b.mp3"}}], + "resolution":"768P","duration":5,"ratio":"adaptive"}`, + wantAction: "image_to_video", + }, + { + name: "content passthrough prepends the prompt when no text item exists", + request: map[string]any{"prompt": "p", "metadata": map[string]any{"content": []any{ + map[string]any{"type": "image_url", "role": "reference_image", "image_url": map[string]any{"url": "img.png"}}, + }}}, + wantBody: `{"model":"MiniMax-H3","content":[ + {"type":"text","text":"p"}, + {"type":"image_url","role":"reference_image","image_url":{"url":"img.png"}}], + "resolution":"768P","duration":5,"ratio":"adaptive"}`, + wantAction: "image_to_video", + }, + { + name: "content passthrough keeps an existing text item", + request: map[string]any{"prompt": "ignored", "metadata": map[string]any{"content": []any{ + map[string]any{"type": "text", "text": "kept"}, + }}}, + wantBody: `{"model":"MiniMax-H3","content":[{"type":"text","text":"kept"}],"resolution":"768P","duration":5,"ratio":"16:9"}`, + wantAction: "text_to_video", + }, + { + name: "explicit ratio callback and watermark", + request: map[string]any{"prompt": "p", "duration": 4, "metadata": map[string]any{ + "ratio": "9:16", + "callback_url": "https://example.com/cb", + "aigc_watermark": true, + }}, + wantBody: `{"model":"MiniMax-H3","content":[{"type":"text","text":"p"}],"resolution":"768P","duration":4,"ratio":"9:16","callback_url":"https://example.com/cb","aigc_watermark":true}`, + wantAction: "text_to_video", + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + descriptor := callHailuoHook(t, plugin, "buildSubmitRequest", hailuoH3SubmitContext(testCase.request)) + assert.Equal(t, "https://api.minimax.example/v2/video_generation", descriptor["url"]) + assert.Equal(t, "POST", descriptor["method"]) + assert.Equal(t, testCase.wantAction, descriptor["action"]) + body, err := common.Marshal(descriptor["body"]) + require.NoError(t, err) + assert.JSONEq(t, testCase.wantBody, string(body)) + }) + } +} + +// Every MiniMax-H3 request bound is rejected before the upstream call, so an +// out-of-range duration can never reach quota calculation as a billing fact. +func TestHailuoH3RejectsOutOfContractRequests(t *testing.T) { + plugin := loadHailuoPlugin(t) + tenReferenceImages := make([]any, 0, 10) + for i := 0; i < 10; i++ { + tenReferenceImages = append(tenReferenceImages, map[string]any{ + "type": "image_url", "role": "reference_image", "image_url": map[string]any{"url": "u"}, + }) + } + testCases := []struct { + name string + request map[string]any + wantErr string + }{ + {"duration below the minimum", map[string]any{"prompt": "p", "duration": 3}, "duration must be an integer between 4 and 15"}, + {"duration above the maximum", map[string]any{"prompt": "p", "duration": 16}, "duration must be an integer between 4 and 15"}, + {"fractional duration", map[string]any{"prompt": "p", "duration": 5.5}, "duration must be an integer between 4 and 15"}, + {"unsupported resolution", map[string]any{"prompt": "p", "size": "1080P"}, "resolution must be 768P or 2K"}, + {"unknown ratio", map[string]any{"prompt": "p", "metadata": map[string]any{"ratio": "16:10"}}, "ratio must be one of"}, + {"adaptive ratio without a visual input", map[string]any{"prompt": "p", "metadata": map[string]any{"ratio": "adaptive"}}, "ratio adaptive requires an image or video input"}, + {"too many frame images", map[string]any{"prompt": "p", "images": []any{"a.png", "b.png", "c.png"}}, "at most 2 frame images"}, + {"media without text", map[string]any{"images": []any{"a.png"}}, "requires a non-empty text item"}, + {"content is not an array", map[string]any{"prompt": "p", "metadata": map[string]any{"content": "nope"}}, "metadata.content must be an array"}, + { + name: "multiple first frame roles", + request: map[string]any{"prompt": "p", "metadata": map[string]any{"content": []any{ + map[string]any{"type": "image_url", "role": "first_frame", "image_url": map[string]any{"url": "u1"}}, + map[string]any{"type": "image_url", "role": "first_frame", "image_url": map[string]any{"url": "u2"}}, + }}}, + wantErr: "at most one first_frame image", + }, + { + name: "passthrough mixes frame and reference media", + request: map[string]any{"prompt": "p", "metadata": map[string]any{"content": []any{ + map[string]any{"type": "image_url", "role": "first_frame", "image_url": map[string]any{"url": "frame"}}, + map[string]any{"type": "image_url", "role": "reference_image", "image_url": map[string]any{"url": "reference"}}, + }}}, + wantErr: "cannot mix frame images with reference media", + }, + { + name: "assembled content mixes frame and reference media", + request: map[string]any{"prompt": "p", "images": []any{"frame"}, "metadata": map[string]any{ + "reference_video": "reference.mp4", + }}, + wantErr: "cannot mix frame images with reference media", + }, + { + name: "too many reference videos", + request: map[string]any{"prompt": "p", "metadata": map[string]any{"content": []any{ + map[string]any{"type": "video_url", "video_url": map[string]any{"url": "u1"}}, + map[string]any{"type": "video_url", "video_url": map[string]any{"url": "u2"}}, + map[string]any{"type": "video_url", "video_url": map[string]any{"url": "u3"}}, + map[string]any{"type": "video_url", "video_url": map[string]any{"url": "u4"}}, + }}}, + wantErr: "at most 3 reference videos", + }, + { + name: "too many reference images", + request: map[string]any{"prompt": "p", "metadata": map[string]any{"content": tenReferenceImages}}, + wantErr: "at most 9 reference images", + }, + { + name: "too many reference audios", + request: map[string]any{"prompt": "p", "metadata": map[string]any{"reference_audio": []any{"a.mp3", "b.mp3", "c.mp3", "d.mp3"}}}, + wantErr: "at most 3 reference audios", + }, + {"empty input", map[string]any{"prompt": " "}, "requires a prompt or a media input"}, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + _, err := plugin.Engine.Call(t.Context(), "buildSubmitRequest", hailuoH3SubmitContext(testCase.request)) + require.ErrorContains(t, err, testCase.wantErr) + }) + } +} + +// The /v1 models keep the flat request shape and endpoint. +func TestHailuoLegacySubmitRequestUnchanged(t *testing.T) { + plugin := loadHailuoPlugin(t) + ctx := hailuoH3SubmitContext(map[string]any{"prompt": "p", "duration": 10, "size": "768P"}) + ctx["model"] = "MiniMax-Hailuo-2.3" + ctx["upstreamModel"] = "MiniMax-Hailuo-2.3" + descriptor := callHailuoHook(t, plugin, "buildSubmitRequest", ctx) + assert.Equal(t, "https://api.minimax.example/v1/video_generation", descriptor["url"]) + body, err := common.Marshal(descriptor["body"]) + require.NoError(t, err) + assert.JSONEq(t, `{"model":"MiniMax-Hailuo-2.3","prompt":"p","duration":10,"resolution":"768P"}`, string(body)) +} + +func TestHailuoQueryRequestEndpointByModel(t *testing.T) { + plugin := loadHailuoPlugin(t) + testCases := []struct { + name string + ctx map[string]any + want string + }{ + { + name: "H3 uses the v2 path parameter", + ctx: map[string]any{"taskId": "task/1", "upstreamModel": "MiniMax-H3"}, + want: "https://api.minimax.example/v2/query/video_generation/task%2F1", + }, + { + name: "an unmapped task falls back to the origin model", + ctx: map[string]any{"taskId": "t1", "model": "MiniMax-H3"}, + want: "https://api.minimax.example/v2/query/video_generation/t1", + }, + { + name: "legacy models keep the v1 query parameter", + ctx: map[string]any{"taskId": "t1", "upstreamModel": "MiniMax-Hailuo-2.3"}, + want: "https://api.minimax.example/v1/query/video_generation?task_id=t1", + }, + { + name: "a channel-mapped alias resolves through the upstream model", + ctx: map[string]any{"taskId": "t1", "model": "h3", "upstreamModel": "MiniMax-H3"}, + want: "https://api.minimax.example/v2/query/video_generation/t1", + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + testCase.ctx["baseUrl"] = "https://api.minimax.example" + testCase.ctx["apiKey"] = "test-ak" + descriptor := callHailuoHook(t, plugin, "buildQueryRequest", testCase.ctx) + assert.Equal(t, testCase.want, descriptor["url"]) + assert.Equal(t, "GET", descriptor["method"]) + }) + } +} + +func TestHailuoParseTaskResult(t *testing.T) { + plugin := loadHailuoPlugin(t) + testCases := []struct { + name string + body string + wantStatus string + wantURL string + wantReason string + }{ + {"H3 queued", `{"task":{"id":"1","status":"queued"}}`, "QUEUED", "", ""}, + {"H3 running", `{"task":{"id":"1","status":"running"}}`, "IN_PROGRESS", "", ""}, + {"H3 succeeded", `{"task":{"id":"1","status":"succeeded","content":{"url":"https://cdn.example/h3.mp4"}}}`, "SUCCESS", "https://cdn.example/h3.mp4", ""}, + {"H3 failed", `{"task":{"id":"1","status":"failed","error":{"code":"1026","message":"sensitive content"}}}`, "FAILURE", "", "sensitive content"}, + {"H3 cancelled", `{"task":{"id":"1","status":"cancelled"}}`, "FAILURE", "", "task cancelled"}, + {"H3 permanent query error", `{"type":"error","error":{"type":"authorized_error","message":"login failed","http_code":"401"}}`, "FAILURE", "", "login failed"}, + {"legacy success", `{"task_id":"1","status":"Success","file_id":"f1","base_resp":{"status_code":0}}`, "SUCCESS", "", ""}, + {"legacy processing", `{"task_id":"1","status":"Processing","base_resp":{"status_code":0}}`, "IN_PROGRESS", "", ""}, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + var body any + require.NoError(t, common.UnmarshalJsonStr(testCase.body, &body)) + result := callHailuoHook(t, plugin, "parseTaskResult", map[string]any{}, body) + assert.Equal(t, testCase.wantStatus, result["status"]) + assert.Equal(t, testCase.wantURL, common.Interface2String(result["url"])) + assert.Equal(t, testCase.wantReason, common.Interface2String(result["reason"])) + }) + } + t.Run("H3 retryable query error", func(t *testing.T) { + var body any + require.NoError(t, common.UnmarshalJsonStr( + `{"type":"error","error":{"type":"rate_limit_error","message":"retry later","http_code":"429"}}`, &body)) + _, err := plugin.Engine.Call(t.Context(), "parseTaskResult", map[string]any{}, body) + require.ErrorContains(t, err, "retry later") + }) +} + +func TestHailuoExtractUsageFacts(t *testing.T) { + plugin := loadHailuoPlugin(t) + testCases := []struct { + name string + model string + request map[string]any + want map[string]any + }{ + {"H3 defaults", "MiniMax-H3", map[string]any{"prompt": "p"}, map[string]any{"seconds": float64(5), "resolution": "768P"}}, + {"H3 2K", "MiniMax-H3", map[string]any{"prompt": "p", "duration": 12, "size": "2K"}, map[string]any{"seconds": float64(12), "resolution": "2K"}}, + {"legacy model", "MiniMax-Hailuo-2.3", map[string]any{"prompt": "p", "duration": 10}, map[string]any{"seconds": float64(10), "resolution": "768P"}}, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + ctx := hailuoH3SubmitContext(testCase.request) + ctx["model"] = testCase.model + ctx["upstreamModel"] = testCase.model + assert.Equal(t, testCase.want, callHailuoHook(t, plugin, "extractUsage", ctx)) + }) + } +} + +// The /v2 result is a public CDN URL, so its artifact is proxied without +// channel credentials instead of through the /v1 file download endpoint. +func TestHailuoH3ArtifactContentProxy(t *testing.T) { + source, err := builtinplugins.Source("hailuo") + require.NoError(t, err) + plugin, err := jsplugin.NewRegistry().RegisterFactory(source, jsplugin.Options{Key: "hailuo"}) + require.NoError(t, err) + adaptor := taskplugin.New(plugin) + adaptor.Init(&relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ApiKey: "test-ak", ChannelBaseUrl: "https://api.minimax.example"}, + }) + task := &model.Task{ + TaskID: "task-public", + Status: model.TaskStatusSuccess, + Data: []byte(`{"task":{"id":"1","status":"succeeded","content":{"url":"https://cdn.example/h3.mp4"}}}`), + } + + artifacts, err := adaptor.ListArtifacts(task) + require.NoError(t, err) + assert.Equal(t, []channel.TaskArtifact{{Key: "video", Type: "video", MimeType: "video/mp4"}}, artifacts) + + descriptor, err := adaptor.BuildContentRequest(task, "video", channel.TaskArtifactClientRequest{Method: http.MethodGet}) + require.NoError(t, err) + require.NotNil(t, descriptor) + assert.Equal(t, "https://cdn.example/h3.mp4", descriptor.URL) + assert.True(t, descriptor.Credentialless) + + pending := &model.Task{TaskID: "task-public", Status: model.TaskStatusInProgress, Data: task.Data} + pendingArtifacts, err := adaptor.ListArtifacts(pending) + require.NoError(t, err) + assert.Empty(t, pendingArtifacts) +} + +// The /v2 create response carries only task_id, without the /v1 base_resp envelope. +func TestHailuoParseSubmitResponse(t *testing.T) { + plugin := loadHailuoPlugin(t) + t.Run("H3 create has no envelope", func(t *testing.T) { + parsed := callHailuoHook(t, plugin, "parseSubmitResponse", map[string]any{"upstreamModel": "MiniMax-H3"}, + map[string]any{"body": map[string]any{"task_id": "h3-1"}}) + assert.Equal(t, "h3-1", parsed["taskId"]) + }) + t.Run("H3 rejection reports the envelope message", func(t *testing.T) { + _, err := plugin.Engine.Call(t.Context(), "parseSubmitResponse", map[string]any{"upstreamModel": "MiniMax-H3"}, + map[string]any{"body": map[string]any{"base_resp": map[string]any{"status_code": 2013, "status_msg": "invalid params"}}}) + require.ErrorContains(t, err, "invalid params") + }) + t.Run("H3 rejection reports the v2 error message", func(t *testing.T) { + _, err := plugin.Engine.Call(t.Context(), "parseSubmitResponse", map[string]any{"upstreamModel": "MiniMax-H3"}, + map[string]any{"body": map[string]any{"type": "error", "error": map[string]any{ + "type": "bad_request_error", "message": "content requires text", "http_code": "400", + }}}) + require.ErrorContains(t, err, "content requires text") + }) + t.Run("legacy create", func(t *testing.T) { + parsed := callHailuoHook(t, plugin, "parseSubmitResponse", map[string]any{"upstreamModel": "MiniMax-Hailuo-2.3"}, + map[string]any{"body": map[string]any{"task_id": "v1-1", "base_resp": map[string]any{"status_code": 0}}}) + assert.Equal(t, "v1-1", parsed["taskId"]) + }) + t.Run("legacy response without an envelope is rejected", func(t *testing.T) { + ctx := map[string]any{"upstreamModel": "MiniMax-Hailuo-2.3"} + _, err := plugin.Engine.Call(t.Context(), "parseSubmitResponse", ctx, + map[string]any{"body": map[string]any{"task_id": "v1-1"}}) + require.ErrorContains(t, err, "hailuo submit failed") + }) + t.Run("legacy error", func(t *testing.T) { + _, err := plugin.Engine.Call(t.Context(), "parseSubmitResponse", map[string]any{"upstreamModel": "MiniMax-Hailuo-2.3"}, + map[string]any{"body": map[string]any{"base_resp": map[string]any{"status_code": 1026, "status_msg": "sensitive"}}}) + require.ErrorContains(t, err, "sensitive") + }) +} + +// Without an H3 exemption the shared /v1 combo table would reject every H3 +// duration on the OpenAI Video path before the request is ever built. +func TestHailuoH3PassesOpenAIVideoDecode(t *testing.T) { + plugin := loadHailuoPlugin(t) + value, err := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_video", "decodeRequest"}, + map[string]any{ + "body": map[string]any{"kind": "json", "value": map[string]any{"model": "MiniMax-H3", "prompt": "p", "seconds": 12, "size": "2K"}}, + "model": "MiniMax-H3", + "upstreamModel": "MiniMax-H3", + }) + require.NoError(t, err) + encoded, err := common.Marshal(value) + require.NoError(t, err) + var intent map[string]any + require.NoError(t, common.Unmarshal(encoded, &intent)) + assert.Equal(t, "submit", intent["kind"]) + requestBody, ok := intent["requestBody"].(map[string]any) + require.True(t, ok) + assert.Equal(t, float64(12), requestBody["duration"]) +} diff --git a/plugins/tasks/hailuo/plugin.js b/plugins/tasks/hailuo/plugin.js index b26397609e..e43f0901be 100644 --- a/plugins/tasks/hailuo/plugin.js +++ b/plugins/tasks/hailuo/plugin.js @@ -4,13 +4,14 @@ export const meta = { name: "Hailuo Video", icon: "Hailuo.Color", description: { - en: "MiniMax Hailuo video generation (text-to-video and image-to-video)", - zh: "MiniMax 海螺视频生成(文生视频、图生视频)", + en: "MiniMax Hailuo video generation (text-to-video, image-to-video, and MiniMax-H3 multimodal reference)", + zh: "MiniMax 海螺视频生成(文生视频、图生视频、MiniMax-H3 多模态参考生视频)", }, - version: "1.0.0", + version: "1.1.0", author: { name: "QuantumNous" }, channelTypes: [35], models: [ + "MiniMax-H3", "MiniMax-Hailuo-2.3", "MiniMax-Hailuo-2.3-Fast", "MiniMax-Hailuo-02", @@ -27,12 +28,12 @@ export const meta = { type: "number", unit: "second", description: { - en: "Requested video duration in seconds. Hailuo 2.3/02/2.3-Fast allow 6 or 10; 01-series allow 6.", - zh: "请求的视频时长,单位为秒。Hailuo 2.3/02/2.3-Fast 允许 6 或 10;01 系列允许 6。", + en: "Requested video duration in seconds. MiniMax-H3 allows 4 to 15; Hailuo 2.3/02/2.3-Fast allow 6 or 10; 01-series allow 6.", + zh: "请求的视频时长,单位为秒。MiniMax-H3 允许 4 到 15;Hailuo 2.3/02/2.3-Fast 允许 6 或 10;01 系列允许 6。", }, }, resolution: { - enum: ["512P", "768P", "720P", "1080P"], + enum: ["512P", "768P", "720P", "1080P", "2K"], description: { en: "Requested output video resolution.", zh: "请求的输出视频分辨率。" }, }, }, @@ -43,6 +44,8 @@ export const meta = { { label: "02 512P 6s", facts: { seconds: 6, resolution: "512P" } }, { label: "02 512P 10s", facts: { seconds: 10, resolution: "512P" } }, { label: "01-series 720P 6s", facts: { seconds: 6, resolution: "720P" } }, + { label: "H3 768P 5s", facts: { seconds: 5, resolution: "768P" } }, + { label: "H3 2K 5s", facts: { seconds: 5, resolution: "2K" } }, ], protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"], }; @@ -96,9 +99,186 @@ function hasHailuoImage(req, hasInputReferenceFile) { ); } +const H3_MODEL = "MiniMax-H3"; +const H3_MIN_DURATION = 4; +const H3_MAX_DURATION = 15; +const H3_DEFAULT_DURATION = 5; +const H3_MAX_FRAME_IMAGES = 2; +const H3_MAX_REFERENCE_IMAGES = 9; +const H3_MAX_REFERENCE_VIDEOS = 3; +const H3_MAX_REFERENCE_AUDIOS = 3; +const H3_RATIOS = ["adaptive", "21:9", "16:9", "4:3", "1:1", "3:4", "9:16"]; + +// MiniMax-H3 speaks the /v2 video generation contract: a multimodal `content` +// array instead of flat frame fields, an explicit `ratio`, 768P/2K resolutions, +// a task id path parameter on query, and a `{task: {...}}` query envelope. +function isH3(model) { + return model === H3_MODEL; +} + +function h3Duration(req) { + const raw = req.duration; + if (raw === undefined || raw === null || raw === "") return H3_DEFAULT_DURATION; + const seconds = Number(raw); + if (!Number.isInteger(seconds) || seconds < H3_MIN_DURATION || seconds > H3_MAX_DURATION) { + throw new Error(H3_MODEL + " duration must be an integer between " + H3_MIN_DURATION + " and " + H3_MAX_DURATION + " seconds"); + } + return seconds; +} + +function h3Resolution(req) { + const metadata = req.metadata || {}; + const raw = trimmed(metadata.resolution) || trimmed(req.resolution) || trimmed(req.size); + if (!raw) return "768P"; + const value = raw.toUpperCase(); + if (value.includes("2K")) return "2K"; + if (value.includes("768")) return "768P"; + throw new Error(H3_MODEL + " resolution must be 768P or 2K"); +} + +function h3MediaItem(type, url, role) { + const item = { type: type, role: role }; + item[type] = { url: url }; + return item; +} + +// Accepts a single value or an array; file placeholders stay objects and are +// resolved by the host after the body is built. +function h3MediaList(source, key) { + const raw = source[key]; + if (raw === undefined || raw === null) return []; + const values = Array.isArray(raw) ? raw : [raw]; + return values.filter(function (value) { + return value && typeof value === "object" ? true : Boolean(trimmed(value)); + }); +} + +function h3FrameImages(req) { + const metadata = req.metadata || {}; + const images = h3MediaList(req, "images"); + if (images.length > H3_MAX_FRAME_IMAGES) throw new Error(H3_MODEL + " accepts at most " + H3_MAX_FRAME_IMAGES + " frame images"); + const frames = []; + if (metadata.first_frame_image) frames.push(h3MediaItem("image_url", metadata.first_frame_image, "first_frame")); + if (metadata.last_frame_image) frames.push(h3MediaItem("image_url", metadata.last_frame_image, "last_frame")); + if (frames.length) return frames; + return images.map(function (url, index) { + return h3MediaItem("image_url", url, index === 0 ? "first_frame" : "last_frame"); + }); +} + +function validateH3Content(items) { + let hasText = false; + let hasFrame = false; + let hasReference = false; + let firstFrames = 0; + let lastFrames = 0; + let referenceImages = 0; + let referenceVideos = 0; + let referenceAudios = 0; + for (const item of items) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const role = trimmed(item.role); + if (item.type === "text" && trimmed(item.text)) { + hasText = true; + continue; + } + if (item.type === "image_url") { + if (!role || role === "first_frame") { + firstFrames += 1; + hasFrame = true; + } else if (role === "last_frame") { + lastFrames += 1; + hasFrame = true; + } else if (role === "middle_frame") { + hasFrame = true; + } else if (role === "reference_image") { + referenceImages += 1; + hasReference = true; + } + continue; + } + if (item.type === "video_url") { + referenceVideos += 1; + hasReference = true; + continue; + } + if (item.type === "audio_url") { + referenceAudios += 1; + hasReference = true; + } + } + if (!hasText) throw new Error(H3_MODEL + " requires a non-empty text item"); + if (firstFrames > 1) throw new Error(H3_MODEL + " accepts at most one first_frame image"); + if (lastFrames > 1) throw new Error(H3_MODEL + " accepts at most one last_frame image"); + if (referenceImages > H3_MAX_REFERENCE_IMAGES) throw new Error(H3_MODEL + " accepts at most " + H3_MAX_REFERENCE_IMAGES + " reference images"); + if (referenceVideos > H3_MAX_REFERENCE_VIDEOS) throw new Error(H3_MODEL + " accepts at most " + H3_MAX_REFERENCE_VIDEOS + " reference videos"); + if (referenceAudios > H3_MAX_REFERENCE_AUDIOS) throw new Error(H3_MODEL + " accepts at most " + H3_MAX_REFERENCE_AUDIOS + " reference audios"); + if (hasFrame && hasReference) throw new Error(H3_MODEL + " cannot mix frame images with reference media"); + return items; +} + +// metadata.content is the full multimodal passthrough; otherwise the content +// array is assembled from prompt, frame images, and reference media. +function h3Content(req) { + const metadata = req.metadata || {}; + const prompt = trimmed(req.prompt); + if (metadata.content !== undefined && metadata.content !== null) { + if (!Array.isArray(metadata.content)) throw new Error("metadata.content must be an array"); + const items = metadata.content; + const hasText = items.some(function (item) { + return item && item.type === "text" && trimmed(item.text); + }); + if (hasText) return validateH3Content(items); + if (!prompt) throw new Error(H3_MODEL + " metadata.content requires a text item or a prompt"); + return validateH3Content([{ type: "text", text: prompt }].concat(items)); + } + const content = prompt ? [{ type: "text", text: prompt }] : []; + for (const frame of h3FrameImages(req)) content.push(frame); + const videos = h3MediaList(metadata, "reference_video"); + if (videos.length > H3_MAX_REFERENCE_VIDEOS) throw new Error(H3_MODEL + " accepts at most " + H3_MAX_REFERENCE_VIDEOS + " reference videos"); + for (const video of videos) content.push(h3MediaItem("video_url", video, "reference_video")); + const audios = h3MediaList(metadata, "reference_audio"); + if (audios.length > H3_MAX_REFERENCE_AUDIOS) throw new Error(H3_MODEL + " accepts at most " + H3_MAX_REFERENCE_AUDIOS + " reference audios"); + for (const audio of audios) content.push(h3MediaItem("audio_url", audio, "reference_audio")); + if (!content.length) throw new Error(H3_MODEL + " requires a prompt or a media input"); + return validateH3Content(content); +} + +function h3HasVisualContent(content) { + return content.some(function (item) { + return item && (item.type === "image_url" || item.type === "video_url"); + }); +} + +// ratio is mandatory upstream and `adaptive` is only meaningful when the +// aspect ratio can be inherited from a visual input. +function h3Ratio(req, content) { + const metadata = req.metadata || {}; + const ratio = trimmed(metadata.ratio); + if (!ratio) return h3HasVisualContent(content) ? "adaptive" : "16:9"; + if (!H3_RATIOS.includes(ratio)) throw new Error(H3_MODEL + " ratio must be one of " + H3_RATIOS.join(", ")); + if (ratio === "adaptive" && !h3HasVisualContent(content)) throw new Error(H3_MODEL + " ratio adaptive requires an image or video input"); + return ratio; +} + +function h3QueryTask(body) { + const task = body && typeof body === "object" && !Array.isArray(body) ? body.task : null; + return task && typeof task === "object" && !Array.isArray(task) ? task : null; +} + +function h3APIError(body) { + const error = body && typeof body === "object" && !Array.isArray(body) ? body.error : null; + if (!error || typeof error !== "object" || Array.isArray(error)) return null; + const message = trimmed(error.message); + if (!message) return null; + const statusCode = Number(error.http_code || error.code || 0); + return { message: message, statusCode: Number.isInteger(statusCode) ? statusCode : 0 }; +} + // Older T2V-01*/I2V-01*/S2V-01 official tables disagree on 1080P support (research: 未验证). // Keep those models permissive: duration 6 only, resolution optional. function validateHailuoCombo(model, duration, resolution, hasImage) { + if (isH3(model)) return; if (model === "MiniMax-Hailuo-2.3-Fast" && !hasImage) { throw new Error("MiniMax-Hailuo-2.3-Fast supports image-to-video only"); } @@ -170,6 +350,26 @@ export function buildSubmitRequest(ctx) { const req = ctx.requestBody || {}; const model = ctx.upstreamModel; const metadata = req.metadata || {}; + if (isH3(model)) { + const content = h3Content(req); + const h3Body = { + model: model, + content: content, + resolution: h3Resolution(req), + duration: h3Duration(req), + ratio: h3Ratio(req, content), + }; + ["callback_url", "aigc_watermark"].forEach(function (key) { + if (metadata[key] !== undefined && metadata[key] !== null) h3Body[key] = metadata[key]; + }); + return { + url: ctx.baseUrl + "/v2/video_generation", + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: "Bearer " + ctx.apiKey }, + body: h3Body, + action: h3HasVisualContent(content) ? "image_to_video" : "text_to_video", + }; + } const body = { model: model, prompt: req.prompt || undefined, @@ -192,8 +392,16 @@ export function buildSubmitRequest(ctx) { export function parseSubmitResponse(ctx, resp) { const body = resp.body || {}; - const base = body.base_resp || {}; - if (base.status_code !== 0) throw new Error(base.status_msg || "hailuo submit failed"); + const apiError = isH3(ctx.upstreamModel) ? h3APIError(body) : null; + if (apiError) throw new Error(apiError.message); + const base = body.base_resp; + // /v1 always wraps the create response in a base_resp envelope; /v2 returns a + // bare task_id and only adds base_resp when the call is rejected. + if (base) { + if (base.status_code !== 0) throw new Error(base.status_msg || "hailuo submit failed"); + } else if (!isH3(ctx.upstreamModel)) { + throw new Error("hailuo submit failed"); + } if (!body.task_id) throw new Error("missing task_id"); return { taskId: body.task_id, taskData: body }; } @@ -202,18 +410,45 @@ export function extractUsage(ctx) { if (ctx.usagePurpose === "billing_ratios") return null; const req = ctx.requestBody || {}; const model = ctx.upstreamModel || req.model; + if (isH3(model)) return { seconds: h3Duration(req), resolution: h3Resolution(req) }; return { seconds: outboundDuration(req), resolution: outboundResolution(req, model) }; } export function buildQueryRequest(ctx) { + // Polling carries no relay info; the host fills these identities from the + // persisted task properties. + const path = isH3(ctx.upstreamModel || ctx.model) + ? "/v2/query/video_generation/" + encodeURIComponent(ctx.taskId) + : "/v1/query/video_generation?task_id=" + encodeURIComponent(ctx.taskId); return { - url: ctx.baseUrl + "/v1/query/video_generation?task_id=" + encodeURIComponent(ctx.taskId), + url: ctx.baseUrl + path, method: "GET", headers: { Accept: "application/json", Authorization: "Bearer " + ctx.apiKey }, }; } export function parseTaskResult(ctx, body) { + // The host calls this hook with an empty context, so the response envelope is + // the only way to tell a /v2 result from a /v1 one. + const apiError = h3APIError(body); + if (apiError) { + if (apiError.statusCode === 408 || apiError.statusCode === 429 || apiError.statusCode >= 500) throw new Error(apiError.message); + return { code: apiError.statusCode, status: "FAILURE", progress: "100%", reason: apiError.message }; + } + const h3Task = h3QueryTask(body); + if (h3Task) { + const h3Statuses = { queued: "QUEUED", running: "IN_PROGRESS", succeeded: "SUCCESS", failed: "FAILURE", cancelled: "FAILURE" }; + const h3Status = h3Statuses[h3Task.status] || "IN_PROGRESS"; + const h3Result = { code: 0, status: h3Status, progress: h3Status === "QUEUED" ? "30%" : h3Status === "IN_PROGRESS" ? "50%" : "100%" }; + if (h3Status === "SUCCESS") { + const url = trimmed(h3Task.content && h3Task.content.url); + if (url) h3Result.url = url; + } + if (h3Status === "FAILURE") { + h3Result.reason = trimmed(h3Task.error && h3Task.error.message) || "task " + trimmed(h3Task.status); + } + return h3Result; + } const base = body.base_resp || {}; const statuses = { Preparing: "IN_PROGRESS", Queueing: "IN_PROGRESS", Processing: "IN_PROGRESS", Success: "SUCCESS", Fail: "FAILURE" }; const status = statuses[body.status] || "IN_PROGRESS"; @@ -232,14 +467,25 @@ function artifactFileID(ctx) { return trimmed(artifactData(ctx).file_id); } +// /v2 tasks expose a public CDN URL instead of a downloadable file id. +function h3ArtifactURL(ctx) { + const task = h3QueryTask(artifactData(ctx)); + return task ? trimmed(task.content && task.content.url) : ""; +} + export function listArtifacts(task) { - return task.status === "SUCCESS" && artifactFileID(task) ? [{ key: "video", type: "video", mimeType: "video/mp4" }] : []; + if (task.status !== "SUCCESS") return []; + return artifactFileID(task) || h3ArtifactURL(task) ? [{ key: "video", type: "video", mimeType: "video/mp4" }] : []; } export function buildContentRequest(ctx) { if (ctx.artifactKey !== "video") throw new Error("artifact_not_found"); const fileID = artifactFileID(ctx); - if (!fileID) throw new Error("artifact_not_found"); + if (!fileID) { + const url = h3ArtifactURL(ctx); + if (!url) throw new Error("artifact_not_found"); + return { url: url, method: ctx.clientRequest.method, credentialless: true }; + } return { url: ctx.baseUrl + "/v1/files/download?file_id=" + encodeURIComponent(fileID), method: ctx.clientRequest.method, @@ -248,6 +494,11 @@ export function buildContentRequest(ctx) { } export function extractUsageOnComplete(_task, _taskResult, body) { + const h3Task = h3QueryTask(body); + if (h3Task) { + const resolution = trimmed(h3Task.resolution).toUpperCase(); + return resolution === "2K" || resolution === "768P" ? { resolution: resolution } : null; + } const width = Number((body || {}).video_width || 0); const height = Number((body || {}).video_height || 0); if (!(width > 0) || !(height > 0)) return null; diff --git a/relay/channel/task/jsplugin/adaptor.go b/relay/channel/task/jsplugin/adaptor.go index 5a27413456..7492276d52 100644 --- a/relay/channel/task/jsplugin/adaptor.go +++ b/relay/channel/task/jsplugin/adaptor.go @@ -528,6 +528,16 @@ func (a *TaskAdaptor) FetchBatchTasks(baseURL, key string, taskIDs []string, pro func (a *TaskAdaptor) FetchTask(baseURL, key string, body map[string]any, proxy string) (*http.Response, error) { ctx := map[string]any{"taskId": body["task_id"], "action": body["action"], "requestBody": body, "baseUrl": baseURL} + // Query hooks are driver hooks and must see the same model identities as + // submit hooks. Polling has no relay info, so they arrive with the + // persisted task properties the caller puts in the fetch body. + originModel, _ := body["model"].(string) + upstreamModel, _ := body["upstream_model"].(string) + if upstreamModel == "" { + upstreamModel = originModel + } + ctx["model"] = originModel + ctx["upstreamModel"] = upstreamModel auth, err := resolveAuth(a.plugin.Meta.Auth, key, proxy) if err != nil { return nil, err diff --git a/relay/channel/task/jsplugin/adaptor_test.go b/relay/channel/task/jsplugin/adaptor_test.go index 136844f640..dd7fa3d859 100644 --- a/relay/channel/task/jsplugin/adaptor_test.go +++ b/relay/channel/task/jsplugin/adaptor_test.go @@ -1208,3 +1208,51 @@ func TestTaskAdaptorBuildSubmitReceivesMappedUpstreamModel(t *testing.T) { assert.Equal(t, "declared-model", decoded["upstreamModel"]) assert.Equal(t, "declared-model", decoded["model"]) } + +// Polling has no relay info, so query hooks can only branch on the model when +// the host forwards the persisted task identities from the fetch body. +func TestTaskAdaptorFetchTaskExposesModelIdentities(t *testing.T) { + service.InitHttpClient() + var requested string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requested = r.URL.RequestURI() + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + source := ` +export const meta = {apiVersion:1,key:"query-model",name:"Query Model",version:"1.0.0",author:{name:"Test"},models:["alias"],fetchMode:"per_task"}; +export function buildSubmitRequest(ctx){return {url:ctx.baseUrl+"/submit"}} +export function parseSubmitResponse(){return {taskId:"1"}} +export function buildQueryRequest(ctx){return {url:ctx.baseUrl+"/tasks/"+ctx.model+"/"+ctx.upstreamModel+"/"+ctx.taskId,method:"GET"}} +export function parseTaskResult(){return {status:"SUCCESS"}} +` + plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{}) + require.NoError(t, err) + adaptor := New(plugin) + + testCases := []struct { + name string + body map[string]any + want string + }{ + { + name: "mapped model", + body: map[string]any{"task_id": "t1", "model": "alias", "upstream_model": "declared-model"}, + want: "/tasks/alias/declared-model/t1", + }, + { + name: "unmapped model falls back to the origin name", + body: map[string]any{"task_id": "t1", "model": "alias"}, + want: "/tasks/alias/alias/t1", + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + resp, fetchErr := adaptor.FetchTask(server.URL, "secret", testCase.body, "") + require.NoError(t, fetchErr) + require.NoError(t, resp.Body.Close()) + assert.Equal(t, testCase.want, requested) + }) + } +} diff --git a/relay/relay_task.go b/relay/relay_task.go index ed39570606..daa1a9ea87 100644 --- a/relay/relay_task.go +++ b/relay/relay_task.go @@ -518,8 +518,10 @@ func tryRealtimeFetch(task *model.Task, isOpenAIVideoAPI bool) []byte { } resp, err := adaptor.FetchTask(baseURL, channelModel.Key, map[string]any{ - "task_id": task.GetUpstreamTaskID(), - "action": constant.NormalizeTaskAction(task.Action), + "task_id": task.GetUpstreamTaskID(), + "action": constant.NormalizeTaskAction(task.Action), + "model": task.Properties.OriginModelName, + "upstream_model": task.Properties.UpstreamModelName, }, proxy) if err != nil || resp == nil { return nil diff --git a/service/task_polling.go b/service/task_polling.go index 5937584493..c6133ac5fa 100644 --- a/service/task_polling.go +++ b/service/task_polling.go @@ -448,8 +448,10 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * key = privateData.Key } resp, err := adaptor.FetchTask(baseURL, key, map[string]any{ - "task_id": task.GetUpstreamTaskID(), - "action": constant.NormalizeTaskAction(task.Action), + "task_id": task.GetUpstreamTaskID(), + "action": constant.NormalizeTaskAction(task.Action), + "model": task.Properties.OriginModelName, + "upstream_model": task.Properties.UpstreamModelName, }, proxy) if err != nil { return fmt.Errorf("fetchTask failed for task %s: %w", taskId, err)