mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-07 18:18:00 +00:00
Channel model_mapping keys exposed in a channel's model list now act as first-class aliases for task-plugin models across the whole line: - Derived alias view (model/task_model_alias.go): built from enabled channels' model_mapping, chain-following with cycle detection, declared names always win, cross-plugin conflicts dropped. Rebuilt on channel cache refresh, registry generation change, and a 60s TTL. - Request path: PinTaskPluginEndpoint resolves declared-name case folds and mapping aliases before endpoint lookup (never rewriting the body until the endpoint is claimed), pins with MappedModel, and the decode contract accepts alias echoes without loosening model ownership for normal pins. Legacy /v1/tasks submit folds case variants the same way. Fixes aliases on POST /v1/responses silently falling through to the main relay against task channels. - Mapping order: ModelMappedHelper now runs before the plugin submit hook builds and caches the upstream body, so channel model_mapping actually reaches the upstream request. Plugins receive the mapped name as ctx.upstreamModel in both decode and submit contexts. - Billing: identity stays the origin name; when the alias has no tiered expression, the selected channel's mapping tail expression applies. Pricing page and billing-expr smoke tests resolve aliases to the owning plugin's usage schema. - Case folding: ASCII-only fold with exact-match priority; same-plugin and cross-plugin fold collisions rejected at registration. - Plugins: model-keyed rate tables, req_key derivation, and combo validation in doubao/kling/jimeng/hailuo/vidu/sunoapi now key on ctx.upstreamModel || ctx.model; render/echo paths keep ctx.model.
404 lines
17 KiB
JavaScript
404 lines
17 KiB
JavaScript
export const meta = {
|
||
apiVersion: 1,
|
||
key: "hailuo",
|
||
name: "Hailuo Video",
|
||
icon: "Hailuo.Color",
|
||
description: {
|
||
en: "MiniMax Hailuo video generation (text-to-video and image-to-video)",
|
||
zh: "MiniMax 海螺视频生成(文生视频、图生视频)",
|
||
},
|
||
version: "1.0.0",
|
||
author: { name: "QuantumNous" },
|
||
channelTypes: [35],
|
||
models: [
|
||
"MiniMax-Hailuo-2.3",
|
||
"MiniMax-Hailuo-2.3-Fast",
|
||
"MiniMax-Hailuo-02",
|
||
"T2V-01-Director",
|
||
"T2V-01",
|
||
"I2V-01-Director",
|
||
"I2V-01-live",
|
||
"I2V-01",
|
||
"S2V-01",
|
||
],
|
||
fetchMode: "per_task",
|
||
usageSchema: {
|
||
seconds: {
|
||
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。",
|
||
},
|
||
},
|
||
resolution: {
|
||
enum: ["512P", "768P", "720P", "1080P"],
|
||
description: { en: "Requested output video resolution.", zh: "请求的输出视频分辨率。" },
|
||
},
|
||
},
|
||
usageExamples: [
|
||
{ label: "2.3/02 768P 6s", facts: { seconds: 6, resolution: "768P" } },
|
||
{ label: "2.3/02 768P 10s", facts: { seconds: 10, resolution: "768P" } },
|
||
{ label: "2.3/02 1080P 6s", facts: { seconds: 6, resolution: "1080P" } },
|
||
{ 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" } },
|
||
],
|
||
protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
|
||
};
|
||
|
||
function trimmed(value) {
|
||
return String(value || "").trim();
|
||
}
|
||
|
||
function isModernHailuo(model) {
|
||
return model === "MiniMax-Hailuo-2.3" || model === "MiniMax-Hailuo-2.3-Fast" || model === "MiniMax-Hailuo-02";
|
||
}
|
||
|
||
function defaultResolution(model) {
|
||
if (model === "MiniMax-Hailuo-2.3" || model === "MiniMax-Hailuo-2.3-Fast" || model === "MiniMax-Hailuo-02") return "768P";
|
||
return "720P";
|
||
}
|
||
|
||
function resolutionFor(size, model) {
|
||
const value = String(size || "");
|
||
if (value.includes("1080")) return "1080P";
|
||
if (value.includes("768")) return "768P";
|
||
if (value.includes("720")) return isModernHailuo(model) ? "768P" : "720P";
|
||
if (value.includes("512")) return "512P";
|
||
return defaultResolution(model);
|
||
}
|
||
|
||
function outboundDuration(req) {
|
||
const n = Number(req && req.duration);
|
||
if (Number.isFinite(n) && n > 0) return n;
|
||
return 6;
|
||
}
|
||
|
||
function outboundResolution(req, model) {
|
||
if (req && req.resolution) return resolutionFor(req.resolution, model);
|
||
const metadata = (req && req.metadata) || {};
|
||
if (metadata.resolution) return resolutionFor(metadata.resolution, model);
|
||
if (req && req.size) return resolutionFor(req.size, model);
|
||
return defaultResolution(model);
|
||
}
|
||
|
||
function hasHailuoImage(req, hasInputReferenceFile) {
|
||
if (hasInputReferenceFile) return true;
|
||
const metadata = (req && req.metadata) || {};
|
||
return Boolean(
|
||
trimmed(req && req.input_reference) ||
|
||
trimmed(req && req.image) ||
|
||
(Array.isArray(req && req.images) && req.images.length) ||
|
||
metadata.first_frame_image ||
|
||
metadata.last_frame_image ||
|
||
metadata.subject_reference
|
||
);
|
||
}
|
||
|
||
// 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 (model === "MiniMax-Hailuo-2.3-Fast" && !hasImage) {
|
||
throw new Error("MiniMax-Hailuo-2.3-Fast supports image-to-video only");
|
||
}
|
||
if (!isModernHailuo(model)) {
|
||
if (duration !== undefined && Number(duration) !== 6) throw new Error(model + " duration must be 6");
|
||
return;
|
||
}
|
||
const n = duration === undefined ? 6 : Number(duration);
|
||
if (n !== 6 && n !== 10) throw new Error(model + " duration must be 6 or 10");
|
||
if (n === 10) {
|
||
if (model === "MiniMax-Hailuo-02" && hasImage) {
|
||
if (resolution !== "768P" && resolution !== "512P") throw new Error("MiniMax-Hailuo-02 duration 10 only allows resolution 768P or 512P");
|
||
return;
|
||
}
|
||
if (resolution !== "768P") throw new Error(model + " duration 10 only allows resolution 768P");
|
||
return;
|
||
}
|
||
const allowed = model === "MiniMax-Hailuo-02" && hasImage ? ["512P", "768P", "1080P"] : ["768P", "1080P"];
|
||
if (allowed.indexOf(resolution) < 0) throw new Error(model + " duration 6 only allows resolution " + allowed.join(" or "));
|
||
}
|
||
|
||
function responsesInput(req) {
|
||
const texts = [],
|
||
images = [];
|
||
const input = req.input;
|
||
if (typeof input === "string") texts.push(input);
|
||
else if (Array.isArray(input)) {
|
||
for (const item of input) {
|
||
if (typeof item === "string") {
|
||
texts.push(item);
|
||
continue;
|
||
}
|
||
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
||
const content = item.content === undefined ? [item] : Array.isArray(item.content) ? item.content : [item.content];
|
||
for (const part of content) {
|
||
if (typeof part === "string") {
|
||
texts.push(part);
|
||
continue;
|
||
}
|
||
if (!part || typeof part !== "object" || Array.isArray(part)) continue;
|
||
if (["input_text", "text"].includes(part.type) && typeof part.text === "string") texts.push(part.text);
|
||
if (["input_image", "image_url"].includes(part.type)) {
|
||
let image = part.image_url;
|
||
if (image && typeof image === "object") image = image.url;
|
||
if (trimmed(image)) images.push(trimmed(image));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return {
|
||
prompt: texts
|
||
.filter(function (text) {
|
||
return trimmed(text);
|
||
})
|
||
.join("\n"),
|
||
images: images,
|
||
};
|
||
}
|
||
|
||
function responsesVideoText(ctx) {
|
||
const artifact = ctx && ctx.artifacts && ctx.artifacts.video;
|
||
const url = trimmed(artifact && artifact.url);
|
||
if (!url) throw new Error("video artifact is unavailable");
|
||
const escaped = url.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||
return '<video controls src="' + escaped + '"></video>';
|
||
}
|
||
|
||
export function buildSubmitRequest(ctx) {
|
||
const req = ctx.requestBody || {};
|
||
const model = ctx.upstreamModel;
|
||
const metadata = req.metadata || {};
|
||
const body = {
|
||
model: model,
|
||
prompt: req.prompt || undefined,
|
||
duration: outboundDuration(req),
|
||
resolution: outboundResolution(req, model),
|
||
};
|
||
["prompt_optimizer", "fast_pretreatment", "callback_url", "aigc_watermark", "first_frame_image", "last_frame_image", "subject_reference"].forEach(
|
||
function (key) {
|
||
if (metadata[key] !== undefined && metadata[key] !== null) body[key] = metadata[key];
|
||
}
|
||
);
|
||
return {
|
||
url: ctx.baseUrl + "/v1/video_generation",
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: "Bearer " + ctx.apiKey },
|
||
body: body,
|
||
action: hasHailuoImage(req, false) ? "image_to_video" : "text_to_video",
|
||
};
|
||
}
|
||
|
||
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");
|
||
if (!body.task_id) throw new Error("missing task_id");
|
||
return { taskId: body.task_id, taskData: body };
|
||
}
|
||
|
||
export function extractUsage(ctx) {
|
||
if (ctx.usagePurpose === "billing_ratios") return null;
|
||
const req = ctx.requestBody || {};
|
||
const model = ctx.upstreamModel || req.model;
|
||
return { seconds: outboundDuration(req), resolution: outboundResolution(req, model) };
|
||
}
|
||
|
||
export function buildQueryRequest(ctx) {
|
||
return {
|
||
url: ctx.baseUrl + "/v1/query/video_generation?task_id=" + encodeURIComponent(ctx.taskId),
|
||
method: "GET",
|
||
headers: { Accept: "application/json", Authorization: "Bearer " + ctx.apiKey },
|
||
};
|
||
}
|
||
|
||
export function parseTaskResult(ctx, body) {
|
||
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";
|
||
const progress = status === "SUCCESS" || status === "FAILURE" ? "100%" : body.status === "Processing" ? "50%" : "30%";
|
||
const reason = base.status_code !== 0 ? base.status_msg || "" : status === "FAILURE" ? "task failed" : "";
|
||
return { code: base.status_code || 0, status: status, progress: progress, reason: reason };
|
||
}
|
||
|
||
function artifactData(ctx) {
|
||
const data = (ctx && ctx.data) || {};
|
||
if (data.data && typeof data.data === "object" && data.data.task_id && Object.prototype.hasOwnProperty.call(data.data, "data")) return data.data.data || {};
|
||
return data;
|
||
}
|
||
|
||
function artifactFileID(ctx) {
|
||
return trimmed(artifactData(ctx).file_id);
|
||
}
|
||
|
||
export function listArtifacts(task) {
|
||
return task.status === "SUCCESS" && artifactFileID(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");
|
||
return {
|
||
url: ctx.baseUrl + "/v1/files/download?file_id=" + encodeURIComponent(fileID),
|
||
method: ctx.clientRequest.method,
|
||
headers: { Accept: "video/*", Authorization: "Bearer " + ctx.apiKey },
|
||
};
|
||
}
|
||
|
||
export function extractUsageOnComplete(_task, _taskResult, body) {
|
||
const width = Number((body || {}).video_width || 0);
|
||
const height = Number((body || {}).video_height || 0);
|
||
if (!(width > 0) || !(height > 0)) return null;
|
||
return { resolution: resolutionFor(width + "x" + height, "") };
|
||
}
|
||
|
||
export const protocols = {
|
||
openai_responses: {
|
||
decodeRequest: function (ctx) {
|
||
if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
|
||
const req = ctx.body.value;
|
||
if (!req || typeof req !== "object" || Array.isArray(req)) throw new Error("request body must be an object");
|
||
const model = trimmed(req.model);
|
||
if (!model) throw new Error("model is required");
|
||
if (req.input !== undefined && typeof req.input !== "string" && !Array.isArray(req.input)) throw new Error("input must be a string or array");
|
||
if (req.images !== undefined && !Array.isArray(req.images)) throw new Error("images must be an array");
|
||
if (req.metadata !== undefined && (!req.metadata || typeof req.metadata !== "object" || Array.isArray(req.metadata)))
|
||
throw new Error("metadata must be an object");
|
||
const input = responsesInput(req);
|
||
const prompt = input.prompt || trimmed(req.prompt);
|
||
const images = [];
|
||
for (const image of [req.image, req.input_reference].concat(req.images || [], input.images)) {
|
||
if (trimmed(image) && !images.includes(trimmed(image))) images.push(trimmed(image));
|
||
}
|
||
if (!prompt && images.length === 0) throw new Error("input is required");
|
||
const metadata = Object.assign({}, req.metadata || {});
|
||
if (images.length && !metadata.first_frame_image) metadata.first_frame_image = images[0];
|
||
if (images.length > 1 && !metadata.last_frame_image) metadata.last_frame_image = images[1];
|
||
const requestBody = { model: model, prompt: prompt, metadata: metadata };
|
||
if (images.length) requestBody.images = images;
|
||
if (Object.prototype.hasOwnProperty.call(req, "seconds")) requestBody.duration = req.seconds;
|
||
else if (Object.prototype.hasOwnProperty.call(req, "duration")) requestBody.duration = req.duration;
|
||
if (Object.prototype.hasOwnProperty.call(req, "size")) requestBody.size = req.size;
|
||
else if (Object.prototype.hasOwnProperty.call(req, "resolution")) requestBody.size = req.resolution;
|
||
return { kind: "submit", model: model, action: images.length ? "image_to_video" : "text_to_video", requestBody: requestBody };
|
||
},
|
||
renderEvents: function (ctx, task, previousState) {
|
||
const status = String(task.status || "UNKNOWN").toUpperCase();
|
||
const value = Number(String(task.progress || "").replace("%", ""));
|
||
const progress = Number.isFinite(value) && value >= 0 && value <= 100 ? value : null;
|
||
const state = { status: status, progress: progress };
|
||
if (status === "SUCCESS") {
|
||
const text = responsesVideoText(ctx);
|
||
const events = previousState && previousState.status === status ? [] : [{ type: "output", data: text }];
|
||
return { events: events, state: state, done: true };
|
||
}
|
||
if (status === "FAILURE")
|
||
return { events: [{ type: "error", code: "task_failed", message: task.fail_reason || "task failed" }], state: state, done: true };
|
||
if (previousState && previousState.status === status && previousState.progress === progress) return { events: [], state: state, done: false };
|
||
const event = { type: "progress", message: status.toLowerCase() };
|
||
if (progress !== null) event.progress = progress;
|
||
return { events: [event], state: state, done: false };
|
||
},
|
||
renderFinal: function (ctx, _task) {
|
||
return {
|
||
output: [
|
||
{
|
||
type: "message",
|
||
status: "completed",
|
||
role: "assistant",
|
||
content: [{ type: "output_text", text: responsesVideoText(ctx), annotations: [], logprobs: [] }],
|
||
},
|
||
],
|
||
metadata: { vendor: "hailuo" },
|
||
};
|
||
},
|
||
},
|
||
};
|
||
|
||
const legacyRenderers = {
|
||
openai_video: function (task) {
|
||
const statuses = { NOT_START: "queued", SUBMITTED: "queued", QUEUED: "queued", IN_PROGRESS: "in_progress", SUCCESS: "completed", FAILURE: "failed" };
|
||
const output = {
|
||
id: task.task_id,
|
||
object: "video",
|
||
model: task.properties && task.properties.origin_model_name ? task.properties.origin_model_name : "",
|
||
status: statuses[task.status] || "unknown",
|
||
progress: Number(String(task.progress || "0").replace("%", "")),
|
||
created_at: task.created_at,
|
||
};
|
||
if (task.updated_at) output.completed_at = task.updated_at;
|
||
if (task.data && task.data.base_resp && task.data.base_resp.status_code !== 0) {
|
||
output.error = { message: task.data.base_resp.status_msg, code: String(task.data.base_resp.status_code) };
|
||
}
|
||
return output;
|
||
},
|
||
};
|
||
|
||
protocols.openai_video = {
|
||
decodeRequest: function (ctx) {
|
||
if (!ctx.body || (ctx.body.kind !== "json" && ctx.body.kind !== "multipart")) throw new Error("JSON or multipart body required");
|
||
let req;
|
||
let hasInputReferenceFile = false;
|
||
if (ctx.body.kind === "json") {
|
||
if (!ctx.body.value || Array.isArray(ctx.body.value)) throw new Error("JSON object required");
|
||
req = Object.assign({}, ctx.body.value);
|
||
} else {
|
||
const first = function (name) {
|
||
const values = (ctx.body.fields || {})[name] || [];
|
||
if (values.length > 1) throw new Error(name + " must be provided once");
|
||
return values[0];
|
||
};
|
||
req = {};
|
||
const fields = ctx.body.fields || {};
|
||
for (const name of Object.keys(fields)) {
|
||
req[name] = first(name);
|
||
}
|
||
for (const file of ctx.body.files || []) {
|
||
if (file.field !== "input_reference") throw new Error("unexpected file field: " + file.field);
|
||
if (hasInputReferenceFile) throw new Error("input_reference must be provided once");
|
||
hasInputReferenceFile = true;
|
||
}
|
||
if (req.metadata !== undefined) {
|
||
let parsed;
|
||
try {
|
||
parsed = JSON.parse(req.metadata);
|
||
} catch (e) {
|
||
throw new Error("metadata must be a JSON object string");
|
||
}
|
||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("metadata must be a JSON object string");
|
||
req.metadata = parsed;
|
||
}
|
||
if (req.seconds !== undefined) req.seconds = Number(req.seconds);
|
||
else if (req.duration !== undefined) req.seconds = Number(req.duration);
|
||
}
|
||
const seconds = req.seconds === undefined ? req.duration : req.seconds;
|
||
if (seconds !== undefined) req.duration = Number(seconds);
|
||
if (hasInputReferenceFile) {
|
||
req.metadata = Object.assign({}, req.metadata || {}, {
|
||
first_frame_image: { __fileRef: "request_file:input_reference", encoding: "dataUrl", maxBytes: 20971520 },
|
||
});
|
||
} else {
|
||
const image = trimmed(req.input_reference || req.image);
|
||
if (image) {
|
||
req.metadata = Object.assign({}, req.metadata || {});
|
||
if (!req.metadata.first_frame_image) req.metadata.first_frame_image = image;
|
||
}
|
||
}
|
||
const hasImage = hasHailuoImage(req, hasInputReferenceFile);
|
||
const duration = req.duration === undefined ? undefined : Number(req.duration);
|
||
const comboModel = ctx.upstreamModel || ctx.model;
|
||
validateHailuoCombo(comboModel, duration, outboundResolution(req, comboModel), hasImage);
|
||
return {
|
||
kind: "submit",
|
||
model: ctx.model,
|
||
action: hasImage ? "image_to_video" : "text_to_video",
|
||
requestBody: Object.assign({}, req, { model: ctx.model }),
|
||
};
|
||
},
|
||
render: function (ctx, task) {
|
||
return legacyRenderers.openai_video(task);
|
||
},
|
||
};
|