mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-11 06:30:21 +00:00
fix(plugin): account for MiniMax-H3 input media usage (#7171)
This commit is contained in:
@@ -37,7 +37,7 @@ func TestHailuoResponsesProtocol(t *testing.T) {
|
|||||||
"size": "1920x1080",
|
"size": "1920x1080",
|
||||||
"metadata": map[string]any{"first_frame_image": "https://cdn.example/frame.png"},
|
"metadata": map[string]any{"first_frame_image": "https://cdn.example/frame.png"},
|
||||||
},
|
},
|
||||||
wantUsageKeys: []string{"resolution", "seconds"},
|
wantUsageKeys: []string{"input_images", "input_video_seconds", "resolution", "seconds"},
|
||||||
wantVendorName: "hailuo",
|
wantVendorName: "hailuo",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -368,15 +368,35 @@ func TestHailuoParseTaskResult(t *testing.T) {
|
|||||||
|
|
||||||
func TestHailuoExtractUsageFacts(t *testing.T) {
|
func TestHailuoExtractUsageFacts(t *testing.T) {
|
||||||
plugin := loadHailuoPlugin(t)
|
plugin := loadHailuoPlugin(t)
|
||||||
|
nineReferenceImages := make([]any, 0, 9)
|
||||||
|
for i := 0; i < 9; i++ {
|
||||||
|
nineReferenceImages = append(nineReferenceImages, map[string]any{
|
||||||
|
"type": "image_url", "role": "reference_image", "image_url": map[string]any{"url": "image"},
|
||||||
|
})
|
||||||
|
}
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
name string
|
name string
|
||||||
model string
|
model string
|
||||||
request map[string]any
|
request map[string]any
|
||||||
want 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 defaults", "MiniMax-H3", map[string]any{"prompt": "p"}, map[string]any{
|
||||||
{"H3 2K", "MiniMax-H3", map[string]any{"prompt": "p", "duration": 12, "size": "2K"}, map[string]any{"seconds": float64(12), "resolution": "2K"}},
|
"seconds": float64(5), "resolution": "768P", "input_images": float64(0), "input_video_seconds": float64(0),
|
||||||
{"legacy model", "MiniMax-Hailuo-2.3", map[string]any{"prompt": "p", "duration": 10}, map[string]any{"seconds": float64(10), "resolution": "768P"}},
|
}},
|
||||||
|
{"H3 2K", "MiniMax-H3", map[string]any{"prompt": "p", "duration": 12, "size": "2K"}, map[string]any{
|
||||||
|
"seconds": float64(12), "resolution": "2K", "input_images": float64(0), "input_video_seconds": float64(0),
|
||||||
|
}},
|
||||||
|
{"H3 reference images", "MiniMax-H3", map[string]any{"prompt": "p", "metadata": map[string]any{"content": nineReferenceImages}}, map[string]any{
|
||||||
|
"seconds": float64(5), "resolution": "768P", "input_images": float64(9), "input_video_seconds": float64(0),
|
||||||
|
}},
|
||||||
|
{"H3 reference video reserves total duration limit", "MiniMax-H3", map[string]any{"prompt": "p", "metadata": map[string]any{
|
||||||
|
"reference_video": []any{"one.mp4", "two.mp4", "three.mp4"},
|
||||||
|
}}, map[string]any{
|
||||||
|
"seconds": float64(5), "resolution": "768P", "input_images": float64(0), "input_video_seconds": float64(15),
|
||||||
|
}},
|
||||||
|
{"legacy model", "MiniMax-Hailuo-2.3", map[string]any{"prompt": "p", "duration": 10}, map[string]any{
|
||||||
|
"seconds": float64(10), "resolution": "768P", "input_images": float64(0), "input_video_seconds": float64(0),
|
||||||
|
}},
|
||||||
}
|
}
|
||||||
for _, testCase := range testCases {
|
for _, testCase := range testCases {
|
||||||
t.Run(testCase.name, func(t *testing.T) {
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
@@ -388,6 +408,59 @@ func TestHailuoExtractUsageFacts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHailuoH3CompletionUsageFacts(t *testing.T) {
|
||||||
|
plugin := loadHailuoPlugin(t)
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
want map[string]any
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "actual usage replaces submission estimates",
|
||||||
|
body: `{"task":{"id":"1","status":"succeeded","resolution":"2K","usage":{"output_seconds":5,"input_seconds":7.5,"input_image_count":6}}}`,
|
||||||
|
want: map[string]any{"seconds": float64(5), "resolution": "2K", "input_images": float64(6), "input_video_seconds": float64(7.5)},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero actual usage is retained for settlement",
|
||||||
|
body: `{"task":{"id":"1","status":"succeeded","resolution":"768P","usage":{"output_seconds":4,"input_seconds":0,"input_image_count":0}}}`,
|
||||||
|
want: map[string]any{"seconds": float64(4), "resolution": "768P", "input_images": float64(0), "input_video_seconds": float64(0)},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero output cannot erase the submission reservation",
|
||||||
|
body: `{"task":{"id":"1","status":"succeeded","resolution":"768P","usage":{"output_seconds":0,"input_seconds":0,"input_image_count":0}}}`,
|
||||||
|
want: map[string]any{"resolution": "768P", "input_images": float64(0), "input_video_seconds": float64(0)},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing usage leaves submission estimates untouched",
|
||||||
|
body: `{"task":{"id":"1","status":"succeeded","resolution":"768P"}}`,
|
||||||
|
want: map[string]any{"resolution": "768P"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "out of contract usage cannot become a billing multiplier",
|
||||||
|
body: `{"task":{"id":"1","status":"succeeded","resolution":"2K","usage":{"output_seconds":16,"input_seconds":16,"input_image_count":10}}}`,
|
||||||
|
want: map[string]any{"resolution": "2K"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, testCase := range testCases {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
var body any
|
||||||
|
require.NoError(t, common.UnmarshalJsonStr(testCase.body, &body))
|
||||||
|
assert.Equal(t, testCase.want, callHailuoHook(t, plugin, "extractUsageOnComplete", nil, nil, body))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("polling adaptor carries actual facts into task settlement", func(t *testing.T) {
|
||||||
|
adaptor := taskplugin.New(plugin)
|
||||||
|
result, err := adaptor.ParseTaskResult([]byte(
|
||||||
|
`{"task":{"id":"1","status":"succeeded","resolution":"2K","usage":{"output_seconds":5,"input_seconds":7.5,"input_image_count":6}}}`,
|
||||||
|
))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, map[string]any{
|
||||||
|
"seconds": float64(5), "resolution": "2K", "input_images": float64(6), "input_video_seconds": float64(7.5),
|
||||||
|
}, result.UsageFacts)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// The /v2 result is a public CDN URL, so its artifact is proxied without
|
// The /v2 result is a public CDN URL, so its artifact is proxied without
|
||||||
// channel credentials instead of through the /v1 file download endpoint.
|
// channel credentials instead of through the /v1 file download endpoint.
|
||||||
func TestHailuoH3ArtifactContentProxy(t *testing.T) {
|
func TestHailuoH3ArtifactContentProxy(t *testing.T) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const meta = {
|
|||||||
en: "MiniMax Hailuo video generation (text-to-video, image-to-video, and MiniMax-H3 multimodal reference)",
|
en: "MiniMax Hailuo video generation (text-to-video, image-to-video, and MiniMax-H3 multimodal reference)",
|
||||||
zh: "MiniMax 海螺视频生成(文生视频、图生视频、MiniMax-H3 多模态参考生视频)",
|
zh: "MiniMax 海螺视频生成(文生视频、图生视频、MiniMax-H3 多模态参考生视频)",
|
||||||
},
|
},
|
||||||
version: "1.1.0",
|
version: "1.1.1",
|
||||||
author: { name: "QuantumNous" },
|
author: { name: "QuantumNous" },
|
||||||
channelTypes: [35],
|
channelTypes: [35],
|
||||||
models: [
|
models: [
|
||||||
@@ -36,16 +36,33 @@ export const meta = {
|
|||||||
enum: ["512P", "768P", "720P", "1080P", "2K"],
|
enum: ["512P", "768P", "720P", "1080P", "2K"],
|
||||||
description: { en: "Requested output video resolution.", zh: "请求的输出视频分辨率。" },
|
description: { en: "Requested output video resolution.", zh: "请求的输出视频分辨率。" },
|
||||||
},
|
},
|
||||||
|
input_images: {
|
||||||
|
type: "number",
|
||||||
|
unit: "count",
|
||||||
|
description: {
|
||||||
|
en: "H3 input image count (estimated at submit, actual on completion).",
|
||||||
|
zh: "H3 输入图片数量(提交时预估,完成后按实际值)。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
input_video_seconds: {
|
||||||
|
type: "number",
|
||||||
|
unit: "second",
|
||||||
|
description: {
|
||||||
|
en: "H3 input video duration in seconds (reserved at the request maximum, actual on completion).",
|
||||||
|
zh: "H3 输入视频时长,单位为秒(提交时按请求上限预留,完成后按实际值)。",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
usageExamples: [
|
usageExamples: [
|
||||||
{ label: "2.3/02 768P 6s", facts: { seconds: 6, resolution: "768P" } },
|
{ label: "2.3/02 768P 6s", facts: { seconds: 6, resolution: "768P", input_images: 0, input_video_seconds: 0 } },
|
||||||
{ label: "2.3/02 768P 10s", facts: { seconds: 10, resolution: "768P" } },
|
{ label: "2.3/02 768P 10s", facts: { seconds: 10, resolution: "768P", input_images: 0, input_video_seconds: 0 } },
|
||||||
{ label: "2.3/02 1080P 6s", facts: { seconds: 6, resolution: "1080P" } },
|
{ label: "2.3/02 1080P 6s", facts: { seconds: 6, resolution: "1080P", input_images: 0, input_video_seconds: 0 } },
|
||||||
{ label: "02 512P 6s", facts: { seconds: 6, resolution: "512P" } },
|
{ label: "02 512P 6s", facts: { seconds: 6, resolution: "512P", input_images: 0, input_video_seconds: 0 } },
|
||||||
{ label: "02 512P 10s", facts: { seconds: 10, resolution: "512P" } },
|
{ label: "02 512P 10s", facts: { seconds: 10, resolution: "512P", input_images: 0, input_video_seconds: 0 } },
|
||||||
{ label: "01-series 720P 6s", facts: { seconds: 6, resolution: "720P" } },
|
{ label: "01-series 720P 6s", facts: { seconds: 6, resolution: "720P", input_images: 0, input_video_seconds: 0 } },
|
||||||
{ label: "H3 768P 5s", facts: { seconds: 5, resolution: "768P" } },
|
{ label: "H3 768P 5s", facts: { seconds: 5, resolution: "768P", input_images: 0, input_video_seconds: 0 } },
|
||||||
{ label: "H3 2K 5s", facts: { seconds: 5, resolution: "2K" } },
|
{ label: "H3 2K 5s · 9 images", facts: { seconds: 5, resolution: "2K", input_images: 9, input_video_seconds: 0 } },
|
||||||
|
{ label: "H3 2K 5s · input video", facts: { seconds: 5, resolution: "2K", input_images: 0, input_video_seconds: 15 } },
|
||||||
],
|
],
|
||||||
protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
|
protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
|
||||||
};
|
};
|
||||||
@@ -107,6 +124,7 @@ const H3_MAX_FRAME_IMAGES = 2;
|
|||||||
const H3_MAX_REFERENCE_IMAGES = 9;
|
const H3_MAX_REFERENCE_IMAGES = 9;
|
||||||
const H3_MAX_REFERENCE_VIDEOS = 3;
|
const H3_MAX_REFERENCE_VIDEOS = 3;
|
||||||
const H3_MAX_REFERENCE_AUDIOS = 3;
|
const H3_MAX_REFERENCE_AUDIOS = 3;
|
||||||
|
const H3_MAX_INPUT_VIDEO_SECONDS = 15;
|
||||||
const H3_RATIOS = ["adaptive", "21:9", "16:9", "4:3", "1:1", "3:4", "9:16"];
|
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`
|
// MiniMax-H3 speaks the /v2 video generation contract: a multimodal `content`
|
||||||
@@ -175,6 +193,7 @@ function validateH3Content(items) {
|
|||||||
let referenceImages = 0;
|
let referenceImages = 0;
|
||||||
let referenceVideos = 0;
|
let referenceVideos = 0;
|
||||||
let referenceAudios = 0;
|
let referenceAudios = 0;
|
||||||
|
let inputImages = 0;
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
||||||
const role = trimmed(item.role);
|
const role = trimmed(item.role);
|
||||||
@@ -183,6 +202,7 @@ function validateH3Content(items) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (item.type === "image_url") {
|
if (item.type === "image_url") {
|
||||||
|
inputImages += 1;
|
||||||
if (!role || role === "first_frame") {
|
if (!role || role === "first_frame") {
|
||||||
firstFrames += 1;
|
firstFrames += 1;
|
||||||
hasFrame = true;
|
hasFrame = true;
|
||||||
@@ -211,6 +231,7 @@ function validateH3Content(items) {
|
|||||||
if (firstFrames > 1) throw new Error(H3_MODEL + " accepts at most one first_frame image");
|
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 (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 (referenceImages > H3_MAX_REFERENCE_IMAGES) throw new Error(H3_MODEL + " accepts at most " + H3_MAX_REFERENCE_IMAGES + " reference images");
|
||||||
|
if (inputImages > H3_MAX_REFERENCE_IMAGES) throw new Error(H3_MODEL + " accepts at most " + H3_MAX_REFERENCE_IMAGES + " input images");
|
||||||
if (referenceVideos > H3_MAX_REFERENCE_VIDEOS) throw new Error(H3_MODEL + " accepts at most " + H3_MAX_REFERENCE_VIDEOS + " reference videos");
|
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 (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");
|
if (hasFrame && hasReference) throw new Error(H3_MODEL + " cannot mix frame images with reference media");
|
||||||
@@ -410,8 +431,24 @@ 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) };
|
if (isH3(model)) {
|
||||||
return { seconds: outboundDuration(req), resolution: outboundResolution(req, model) };
|
const content = h3Content(req);
|
||||||
|
return {
|
||||||
|
seconds: h3Duration(req),
|
||||||
|
resolution: h3Resolution(req),
|
||||||
|
input_images: content.filter(function (item) {
|
||||||
|
return item && item.type === "image_url";
|
||||||
|
}).length,
|
||||||
|
// Input URLs do not expose duration. Reserve the documented total limit;
|
||||||
|
// polling replaces it with usage.input_seconds after success.
|
||||||
|
input_video_seconds: content.some(function (item) {
|
||||||
|
return item && item.type === "video_url";
|
||||||
|
})
|
||||||
|
? H3_MAX_INPUT_VIDEO_SECONDS
|
||||||
|
: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { seconds: outboundDuration(req), resolution: outboundResolution(req, model), input_images: 0, input_video_seconds: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildQueryRequest(ctx) {
|
export function buildQueryRequest(ctx) {
|
||||||
@@ -497,7 +534,23 @@ export function extractUsageOnComplete(_task, _taskResult, body) {
|
|||||||
const h3Task = h3QueryTask(body);
|
const h3Task = h3QueryTask(body);
|
||||||
if (h3Task) {
|
if (h3Task) {
|
||||||
const resolution = trimmed(h3Task.resolution).toUpperCase();
|
const resolution = trimmed(h3Task.resolution).toUpperCase();
|
||||||
return resolution === "2K" || resolution === "768P" ? { resolution: resolution } : null;
|
const facts = {};
|
||||||
|
if (resolution === "2K" || resolution === "768P") facts.resolution = resolution;
|
||||||
|
const usage = h3Task.usage && typeof h3Task.usage === "object" && !Array.isArray(h3Task.usage) ? h3Task.usage : {};
|
||||||
|
const fields = [
|
||||||
|
{ key: "seconds", value: usage.output_seconds, minimum: H3_MIN_DURATION, maximum: H3_MAX_DURATION, integer: false },
|
||||||
|
{ key: "input_images", value: usage.input_image_count, minimum: 0, maximum: H3_MAX_REFERENCE_IMAGES, integer: true },
|
||||||
|
{ key: "input_video_seconds", value: usage.input_seconds, minimum: 0, maximum: H3_MAX_INPUT_VIDEO_SECONDS, integer: false },
|
||||||
|
];
|
||||||
|
// Omit malformed or out-of-contract upstream values so settlement keeps
|
||||||
|
// the bounded submission estimate instead of accepting a new multiplier.
|
||||||
|
for (const field of fields) {
|
||||||
|
if (field.value === undefined || field.value === null || field.value === "") continue;
|
||||||
|
const value = Number(field.value);
|
||||||
|
if (!Number.isFinite(value) || value < field.minimum || value > field.maximum || (field.integer && !Number.isInteger(value))) continue;
|
||||||
|
facts[field.key] = value;
|
||||||
|
}
|
||||||
|
return Object.keys(facts).length ? facts : 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);
|
||||||
|
|||||||
Reference in New Issue
Block a user