mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-11 14:41:21 +00:00
feat(plugin): add MiniMax-H3 /v2 video generation to the hailuo task … (#7168)
* feat(plugin): add MiniMax-H3 /v2 video generation to the hailuo task plugin
MiniMax-H3 speaks a different contract from the other Hailuo models, so the
hailuo task plugin now branches on the upstream model instead of adding a Go
adaptor:
- submit builds /v2/video_generation with a multimodal `content` array
(text, first/last frame images, reference video/audio, or a full
`metadata.content` passthrough), an explicit `ratio`, and 768P/2K
resolutions; `metadata.callback_url` and `metadata.aigc_watermark` pass
through
- query uses /v2/query/video_generation/{task_id} and parses the
`{"task": {...}}` envelope, falling back to the /v1 shapes for every other
model
- the /v2 result is a public CDN URL, so its artifact is proxied
credentialless instead of through /v1/files/download
- request bounds (duration 4-15, resolution 768P/2K, ratio whitelist, at most
2 frame images and 9/3/3 reference images/videos/audios) are enforced while
the request body is built, which the host runs during validation, so an
out-of-range duration is rejected with a 400 before it can become a billing
multiplier
- duration and resolution are reported as usage facts only. Like the rest of
this plugin, extractUsage returns no billing ratios, so per-call pricing is
flat and 2K/duration pricing is expressed through the model's tiered billing
expression over those facts.
Query hooks are driver hooks and are documented to receive `ctx.model` and
`ctx.upstreamModel`, but polling has no relay info and never populated them.
The polling and realtime-fetch call sites now carry the persisted task model
properties and the plugin adaptor maps them onto the query context, with
`upstreamModel` falling back to the origin name for tasks submitted without a
channel mapping.
* fix(plugin): validate Hailuo H3 requests and errors
This commit is contained in:
@@ -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.
|
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.
|
||||||
|
|||||||
@@ -70,3 +70,412 @@ func TestHailuoArtifactContentProxy(t *testing.T) {
|
|||||||
assert.Equal(t, map[string]string{"Accept": "video/*", "Authorization": "Bearer test-ak"}, descriptor.Headers)
|
assert.Equal(t, map[string]string{"Accept": "video/*", "Authorization": "Bearer test-ak"}, descriptor.Headers)
|
||||||
assert.False(t, descriptor.Credentialless)
|
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"])
|
||||||
|
}
|
||||||
|
|||||||
+262
-11
@@ -4,13 +4,14 @@ export const meta = {
|
|||||||
name: "Hailuo Video",
|
name: "Hailuo Video",
|
||||||
icon: "Hailuo.Color",
|
icon: "Hailuo.Color",
|
||||||
description: {
|
description: {
|
||||||
en: "MiniMax Hailuo video generation (text-to-video and image-to-video)",
|
en: "MiniMax Hailuo video generation (text-to-video, image-to-video, and MiniMax-H3 multimodal reference)",
|
||||||
zh: "MiniMax 海螺视频生成(文生视频、图生视频)",
|
zh: "MiniMax 海螺视频生成(文生视频、图生视频、MiniMax-H3 多模态参考生视频)",
|
||||||
},
|
},
|
||||||
version: "1.0.0",
|
version: "1.1.0",
|
||||||
author: { name: "QuantumNous" },
|
author: { name: "QuantumNous" },
|
||||||
channelTypes: [35],
|
channelTypes: [35],
|
||||||
models: [
|
models: [
|
||||||
|
"MiniMax-H3",
|
||||||
"MiniMax-Hailuo-2.3",
|
"MiniMax-Hailuo-2.3",
|
||||||
"MiniMax-Hailuo-2.3-Fast",
|
"MiniMax-Hailuo-2.3-Fast",
|
||||||
"MiniMax-Hailuo-02",
|
"MiniMax-Hailuo-02",
|
||||||
@@ -27,12 +28,12 @@ export const meta = {
|
|||||||
type: "number",
|
type: "number",
|
||||||
unit: "second",
|
unit: "second",
|
||||||
description: {
|
description: {
|
||||||
en: "Requested video duration in seconds. Hailuo 2.3/02/2.3-Fast allow 6 or 10; 01-series allow 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: "请求的视频时长,单位为秒。Hailuo 2.3/02/2.3-Fast 允许 6 或 10;01 系列允许 6。",
|
zh: "请求的视频时长,单位为秒。MiniMax-H3 允许 4 到 15;Hailuo 2.3/02/2.3-Fast 允许 6 或 10;01 系列允许 6。",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
resolution: {
|
resolution: {
|
||||||
enum: ["512P", "768P", "720P", "1080P"],
|
enum: ["512P", "768P", "720P", "1080P", "2K"],
|
||||||
description: { en: "Requested output video resolution.", zh: "请求的输出视频分辨率。" },
|
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 6s", facts: { seconds: 6, resolution: "512P" } },
|
||||||
{ label: "02 512P 10s", facts: { seconds: 10, resolution: "512P" } },
|
{ label: "02 512P 10s", facts: { seconds: 10, resolution: "512P" } },
|
||||||
{ label: "01-series 720P 6s", facts: { seconds: 6, resolution: "720P" } },
|
{ 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"],
|
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: 未验证).
|
// Older T2V-01*/I2V-01*/S2V-01 official tables disagree on 1080P support (research: 未验证).
|
||||||
// Keep those models permissive: duration 6 only, resolution optional.
|
// Keep those models permissive: duration 6 only, resolution optional.
|
||||||
function validateHailuoCombo(model, duration, resolution, hasImage) {
|
function validateHailuoCombo(model, duration, resolution, hasImage) {
|
||||||
|
if (isH3(model)) return;
|
||||||
if (model === "MiniMax-Hailuo-2.3-Fast" && !hasImage) {
|
if (model === "MiniMax-Hailuo-2.3-Fast" && !hasImage) {
|
||||||
throw new Error("MiniMax-Hailuo-2.3-Fast supports image-to-video only");
|
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 req = ctx.requestBody || {};
|
||||||
const model = ctx.upstreamModel;
|
const model = ctx.upstreamModel;
|
||||||
const metadata = req.metadata || {};
|
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 = {
|
const body = {
|
||||||
model: model,
|
model: model,
|
||||||
prompt: req.prompt || undefined,
|
prompt: req.prompt || undefined,
|
||||||
@@ -192,8 +392,16 @@ export function buildSubmitRequest(ctx) {
|
|||||||
|
|
||||||
export function parseSubmitResponse(ctx, resp) {
|
export function parseSubmitResponse(ctx, resp) {
|
||||||
const body = resp.body || {};
|
const body = resp.body || {};
|
||||||
const base = body.base_resp || {};
|
const apiError = isH3(ctx.upstreamModel) ? h3APIError(body) : null;
|
||||||
if (base.status_code !== 0) throw new Error(base.status_msg || "hailuo submit failed");
|
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");
|
if (!body.task_id) throw new Error("missing task_id");
|
||||||
return { taskId: body.task_id, taskData: body };
|
return { taskId: body.task_id, taskData: body };
|
||||||
}
|
}
|
||||||
@@ -202,18 +410,45 @@ export function extractUsage(ctx) {
|
|||||||
if (ctx.usagePurpose === "billing_ratios") return null;
|
if (ctx.usagePurpose === "billing_ratios") return null;
|
||||||
const req = ctx.requestBody || {};
|
const req = ctx.requestBody || {};
|
||||||
const model = ctx.upstreamModel || req.model;
|
const model = ctx.upstreamModel || req.model;
|
||||||
|
if (isH3(model)) return { seconds: h3Duration(req), resolution: h3Resolution(req) };
|
||||||
return { seconds: outboundDuration(req), resolution: outboundResolution(req, model) };
|
return { seconds: outboundDuration(req), resolution: outboundResolution(req, model) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildQueryRequest(ctx) {
|
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 {
|
return {
|
||||||
url: ctx.baseUrl + "/v1/query/video_generation?task_id=" + encodeURIComponent(ctx.taskId),
|
url: ctx.baseUrl + path,
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: { Accept: "application/json", Authorization: "Bearer " + ctx.apiKey },
|
headers: { Accept: "application/json", Authorization: "Bearer " + ctx.apiKey },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseTaskResult(ctx, body) {
|
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 base = body.base_resp || {};
|
||||||
const statuses = { Preparing: "IN_PROGRESS", Queueing: "IN_PROGRESS", Processing: "IN_PROGRESS", Success: "SUCCESS", Fail: "FAILURE" };
|
const statuses = { Preparing: "IN_PROGRESS", Queueing: "IN_PROGRESS", Processing: "IN_PROGRESS", Success: "SUCCESS", Fail: "FAILURE" };
|
||||||
const status = statuses[body.status] || "IN_PROGRESS";
|
const status = statuses[body.status] || "IN_PROGRESS";
|
||||||
@@ -232,14 +467,25 @@ function artifactFileID(ctx) {
|
|||||||
return trimmed(artifactData(ctx).file_id);
|
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) {
|
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) {
|
export function buildContentRequest(ctx) {
|
||||||
if (ctx.artifactKey !== "video") throw new Error("artifact_not_found");
|
if (ctx.artifactKey !== "video") throw new Error("artifact_not_found");
|
||||||
const fileID = artifactFileID(ctx);
|
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 {
|
return {
|
||||||
url: ctx.baseUrl + "/v1/files/download?file_id=" + encodeURIComponent(fileID),
|
url: ctx.baseUrl + "/v1/files/download?file_id=" + encodeURIComponent(fileID),
|
||||||
method: ctx.clientRequest.method,
|
method: ctx.clientRequest.method,
|
||||||
@@ -248,6 +494,11 @@ export function buildContentRequest(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function extractUsageOnComplete(_task, _taskResult, body) {
|
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 width = Number((body || {}).video_width || 0);
|
||||||
const height = Number((body || {}).video_height || 0);
|
const height = Number((body || {}).video_height || 0);
|
||||||
if (!(width > 0) || !(height > 0)) return null;
|
if (!(width > 0) || !(height > 0)) return null;
|
||||||
|
|||||||
@@ -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) {
|
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}
|
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)
|
auth, err := resolveAuth(a.plugin.Meta.Auth, key, proxy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -1208,3 +1208,51 @@ func TestTaskAdaptorBuildSubmitReceivesMappedUpstreamModel(t *testing.T) {
|
|||||||
assert.Equal(t, "declared-model", decoded["upstreamModel"])
|
assert.Equal(t, "declared-model", decoded["upstreamModel"])
|
||||||
assert.Equal(t, "declared-model", decoded["model"])
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+4
-2
@@ -518,8 +518,10 @@ func tryRealtimeFetch(task *model.Task, isOpenAIVideoAPI bool) []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resp, err := adaptor.FetchTask(baseURL, channelModel.Key, map[string]any{
|
resp, err := adaptor.FetchTask(baseURL, channelModel.Key, map[string]any{
|
||||||
"task_id": task.GetUpstreamTaskID(),
|
"task_id": task.GetUpstreamTaskID(),
|
||||||
"action": constant.NormalizeTaskAction(task.Action),
|
"action": constant.NormalizeTaskAction(task.Action),
|
||||||
|
"model": task.Properties.OriginModelName,
|
||||||
|
"upstream_model": task.Properties.UpstreamModelName,
|
||||||
}, proxy)
|
}, proxy)
|
||||||
if err != nil || resp == nil {
|
if err != nil || resp == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -448,8 +448,10 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch *
|
|||||||
key = privateData.Key
|
key = privateData.Key
|
||||||
}
|
}
|
||||||
resp, err := adaptor.FetchTask(baseURL, key, map[string]any{
|
resp, err := adaptor.FetchTask(baseURL, key, map[string]any{
|
||||||
"task_id": task.GetUpstreamTaskID(),
|
"task_id": task.GetUpstreamTaskID(),
|
||||||
"action": constant.NormalizeTaskAction(task.Action),
|
"action": constant.NormalizeTaskAction(task.Action),
|
||||||
|
"model": task.Properties.OriginModelName,
|
||||||
|
"upstream_model": task.Properties.UpstreamModelName,
|
||||||
}, proxy)
|
}, proxy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("fetchTask failed for task %s: %w", taskId, err)
|
return fmt.Errorf("fetchTask failed for task %s: %w", taskId, err)
|
||||||
|
|||||||
Reference in New Issue
Block a user