feat: publish sanitized custom.97 source
This commit is contained in:
1022 files changed
+69903
-19207
No files matched your search
+653
-104
File diff suppressed because it is too large.
Load diff
+780
-71
File diff suppressed because it is too large.
Load diff
@@ -7,11 +7,12 @@ export const meta = {
|
||||
en: "Google Veo video generation on the Gemini API (text-to-video and image-to-video)",
|
||||
zh: "Google Veo 视频生成(文生视频、图生视频),Gemini API 版本",
|
||||
},
|
||||
version: "1.0.2",
|
||||
version: "1.1.0",
|
||||
author: { name: "QuantumNous" },
|
||||
channelTypes: [24],
|
||||
models: ["veo-3.0-generate-001", "veo-3.0-fast-generate-001", "veo-3.1-generate-preview", "veo-3.1-fast-generate-preview"],
|
||||
fetchMode: "per_task",
|
||||
upstreams: ["vendor", "new_api"],
|
||||
usageSchema: {
|
||||
// Requested video duration in seconds. Allowed values: 4, 6, 8.
|
||||
seconds: {
|
||||
@@ -32,6 +33,14 @@ export const meta = {
|
||||
{ label: "8s × 4k", facts: { seconds: 8, resolution: "4k" } },
|
||||
{ label: "4s × 720p", facts: { seconds: 4, resolution: "720p" } },
|
||||
],
|
||||
// Gemini API request and response bodies, for clients and for a downstream
|
||||
// New API gateway. The model segment carries the method,
|
||||
// models/{model}:predictLongRunning, and the operation it returns is named
|
||||
// after the gateway task.
|
||||
routes: [
|
||||
{ method: "POST", path: "/google/v1beta/models/:model", type: "submit", decode: "predictLongRunning", render: "operation" },
|
||||
{ method: "GET", path: "/google/v1beta/models/:model/operations/:task_id", type: "query", render: "operation" },
|
||||
],
|
||||
protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
|
||||
};
|
||||
|
||||
@@ -39,6 +48,25 @@ function trimmed(value) {
|
||||
return String(value || "").trim();
|
||||
}
|
||||
|
||||
function plainObject(value) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
||||
}
|
||||
|
||||
// On a New API channel the same plugin answers on another gateway, which
|
||||
// serves the Gemini API wire format on the prefixed native routes and takes
|
||||
// its own token as a Bearer header.
|
||||
function viaGateway(ctx) {
|
||||
return Boolean(ctx.upstream && ctx.upstream.kind === "new_api");
|
||||
}
|
||||
|
||||
function apiRoot(ctx) {
|
||||
return ctx.baseUrl + (viaGateway(ctx) ? "/google" : "");
|
||||
}
|
||||
|
||||
function authHeaders(ctx) {
|
||||
return viaGateway(ctx) ? { Authorization: "Bearer " + ctx.apiKey } : { "x-goog-api-key": ctx.apiKey };
|
||||
}
|
||||
|
||||
function responsesInput(req) {
|
||||
const texts = [],
|
||||
images = [];
|
||||
@@ -160,12 +188,64 @@ function version(ctx) {
|
||||
return settings.geminiVersion || "v1beta";
|
||||
}
|
||||
|
||||
export const native = {
|
||||
predictLongRunning: function (ctx) {
|
||||
const segment = String((ctx.params || {}).model || "");
|
||||
const separator = segment.lastIndexOf(":");
|
||||
if (separator <= 0 || segment.slice(separator + 1) !== "predictLongRunning") throw new Error("the path must name models/{model}:predictLongRunning");
|
||||
const model = segment.slice(0, separator);
|
||||
if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
|
||||
const body = plainObject(ctx.body.value);
|
||||
if (!body) throw new Error("request body must be an object");
|
||||
const instances = Array.isArray(body.instances) ? body.instances : [];
|
||||
const instance = plainObject(instances[0]);
|
||||
if (instances.length !== 1 || !instance) throw new Error("instances must hold exactly one instance");
|
||||
// buildSubmitRequest sends only the prompt and the first frame.
|
||||
for (const key of Object.keys(instance)) {
|
||||
if (key !== "prompt" && key !== "image") throw new Error("instances[0]." + key + " is not supported");
|
||||
}
|
||||
const metadata = Object.assign({}, plainObject(body.parameters) || {});
|
||||
const requestBody = { model: model, prompt: instance.prompt, metadata: metadata };
|
||||
if (metadata.durationSeconds !== undefined) {
|
||||
requestBody.duration = metadata.durationSeconds;
|
||||
delete metadata.durationSeconds;
|
||||
if ([4, 6, 8].indexOf(Number(requestBody.duration)) < 0) throw new Error("durationSeconds must be one of 4, 6, or 8");
|
||||
}
|
||||
if (metadata.resolution !== undefined && ["720p", "1080p", "4k"].indexOf(String(metadata.resolution).toLowerCase()) < 0)
|
||||
throw new Error("resolution must be one of 720p, 1080p, or 4k");
|
||||
if (instance.image !== undefined) {
|
||||
const inline = plainObject(plainObject(instance.image) && instance.image.inlineData);
|
||||
if (!inline || !trimmed(inline.data)) throw new Error("instances[0].image must carry inlineData");
|
||||
requestBody.images = ["data:" + (trimmed(inline.mimeType) || "application/octet-stream") + ";base64," + inline.data];
|
||||
}
|
||||
return { kind: "submit", model: model, action: requestBody.images ? "image_to_video" : "text_to_video", requestBody: requestBody };
|
||||
},
|
||||
// The operation is named after the gateway task, so the caller polls it
|
||||
// here. The poller can fail a task whose operation was still running; that
|
||||
// operation is rendered done with the failure.
|
||||
operation: function (ctx, task) {
|
||||
const data = plainObject(task.data) || {};
|
||||
const named = /^models\/([^/]+)\/operations\//.exec(String(data.name || ""));
|
||||
const model = named ? named[1] : String((ctx.params || {}).model || "").split(":")[0];
|
||||
const output = Object.assign({}, data, { name: "models/" + model + "/operations/" + task.task_id });
|
||||
if (task.status === "FAILURE" && !(plainObject(data.error) && data.error.message)) {
|
||||
output.done = true;
|
||||
output.error = { message: task.fail_reason || "task failed" };
|
||||
delete output.response;
|
||||
}
|
||||
return output;
|
||||
},
|
||||
error: function (ctx, error) {
|
||||
return { error: { code: error.httpStatus, message: error.message } };
|
||||
},
|
||||
};
|
||||
|
||||
export function buildSubmitRequest(ctx) {
|
||||
const result = converted(ctx);
|
||||
return {
|
||||
url: ctx.baseUrl + "/" + version(ctx) + "/models/" + ctx.upstreamModel + ":predictLongRunning",
|
||||
url: apiRoot(ctx) + "/" + version(ctx) + "/models/" + ctx.upstreamModel + ":predictLongRunning",
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json", "x-goog-api-key": ctx.apiKey },
|
||||
headers: Object.assign({ "Content-Type": "application/json", Accept: "application/json" }, authHeaders(ctx)),
|
||||
body: result.body,
|
||||
action: result.action,
|
||||
};
|
||||
@@ -198,9 +278,9 @@ export function extractUsageOnComplete() {
|
||||
|
||||
export function buildQueryRequest(ctx) {
|
||||
return {
|
||||
url: ctx.baseUrl + "/v1beta/" + utils.base64URLDecode(ctx.taskId),
|
||||
url: apiRoot(ctx) + "/v1beta/" + utils.base64URLDecode(ctx.taskId),
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json", "x-goog-api-key": ctx.apiKey },
|
||||
headers: Object.assign({ Accept: "application/json" }, authHeaders(ctx)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -235,6 +315,16 @@ export function buildContentRequest(ctx) {
|
||||
if (ctx.artifactKey !== "video") throw new Error("artifact_not_found");
|
||||
const url = artifactVideoURL(ctx);
|
||||
if (!url) throw new Error("artifact_not_found");
|
||||
// The file needs the Gemini key, which only the gateway holds; it serves the
|
||||
// video as the artifact of its task, the last segment of the operation name.
|
||||
if (viaGateway(ctx)) {
|
||||
const taskID = utils.base64URLDecode(ctx.upstreamTaskId).split("/").pop();
|
||||
return {
|
||||
url: ctx.baseUrl + "/v1/tasks/" + encodeURIComponent(taskID) + "/artifacts/video/content",
|
||||
method: ctx.clientRequest.method,
|
||||
headers: { Authorization: "Bearer " + ctx.apiKey },
|
||||
};
|
||||
}
|
||||
return { url: url, method: ctx.clientRequest.method, headers: { "x-goog-api-key": ctx.apiKey } };
|
||||
}
|
||||
|
||||
|
||||
+178
-45
@@ -53,6 +53,33 @@ const HAILUO_01_USAGE_SCHEMA = {
|
||||
seconds: VIDEO_SECONDS_FIELD,
|
||||
};
|
||||
|
||||
// MiniMax serves MiniMax-H3 on its /v2 video API and every other model on /v1.
|
||||
const H3_MODEL = "MiniMax-H3";
|
||||
const V1_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",
|
||||
];
|
||||
|
||||
// Vendor fields the OpenAI-style requests carry in metadata; the MiniMax body
|
||||
// has them at the top level.
|
||||
const V1_METADATA_FIELDS = [
|
||||
"prompt_optimizer",
|
||||
"fast_pretreatment",
|
||||
"callback_url",
|
||||
"aigc_watermark",
|
||||
"first_frame_image",
|
||||
"last_frame_image",
|
||||
"subject_reference",
|
||||
];
|
||||
const H3_METADATA_FIELDS = ["callback_url", "aigc_watermark"];
|
||||
|
||||
export const meta = {
|
||||
apiVersion: 1,
|
||||
key: "hailuo",
|
||||
@@ -62,22 +89,12 @@ export const meta = {
|
||||
en: "MiniMax Hailuo video generation (text-to-video, image-to-video, and MiniMax-H3 multimodal reference)",
|
||||
zh: "MiniMax 海螺视频生成(文生视频、图生视频、MiniMax-H3 多模态参考生视频)",
|
||||
},
|
||||
version: "1.2.0",
|
||||
version: "1.3.0",
|
||||
author: { name: "QuantumNous" },
|
||||
channelTypes: [35],
|
||||
models: [
|
||||
"MiniMax-H3",
|
||||
"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",
|
||||
],
|
||||
models: [H3_MODEL].concat(V1_MODELS),
|
||||
fetchMode: "per_task",
|
||||
upstreams: ["vendor", "new_api"],
|
||||
// Fallback for a channel alias that resolves to more than one model: the
|
||||
// union of every family's fields and resolutions.
|
||||
usageSchema: Object.assign({}, H3_USAGE_SCHEMA, {
|
||||
@@ -134,6 +151,15 @@ export const meta = {
|
||||
examples: [{ label: "01-series 720P 6s", facts: { seconds: 6 } }],
|
||||
},
|
||||
],
|
||||
// MiniMax request and response bodies, for clients and for a downstream New
|
||||
// API gateway. The task id of a query is a path parameter on both versions,
|
||||
// where MiniMax /v1 reads it from the query string.
|
||||
routes: [
|
||||
{ method: "POST", path: "/hailuo/v1/video_generation", type: "submit", decode: "createTask", render: "taskCreated", models: V1_MODELS },
|
||||
{ method: "GET", path: "/hailuo/v1/query/video_generation/:task_id", type: "query", render: "taskStatus" },
|
||||
{ method: "POST", path: "/hailuo/v2/video_generation", type: "submit", decode: "createH3Task", render: "taskCreated", models: [H3_MODEL] },
|
||||
{ method: "GET", path: "/hailuo/v2/query/video_generation/:task_id", type: "query", render: "h3TaskStatus" },
|
||||
],
|
||||
protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
|
||||
};
|
||||
|
||||
@@ -141,6 +167,29 @@ function trimmed(value) {
|
||||
return String(value || "").trim();
|
||||
}
|
||||
|
||||
function plainObject(value) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
||||
}
|
||||
|
||||
// The members of source that hold a value.
|
||||
function definedFields(source, keys) {
|
||||
const fields = {};
|
||||
for (const key of keys) {
|
||||
if (source[key] !== undefined && source[key] !== null) fields[key] = source[key];
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
// On a New API channel the same plugin answers on another gateway, which
|
||||
// serves the MiniMax wire format only on the prefixed native routes.
|
||||
function viaGateway(ctx) {
|
||||
return Boolean(ctx.upstream && ctx.upstream.kind === "new_api");
|
||||
}
|
||||
|
||||
function apiRoot(ctx) {
|
||||
return ctx.baseUrl + (viaGateway(ctx) ? "/hailuo" : "");
|
||||
}
|
||||
|
||||
function isModernHailuo(model) {
|
||||
return model === "MiniMax-Hailuo-2.3" || model === "MiniMax-Hailuo-2.3-Fast" || model === "MiniMax-Hailuo-02";
|
||||
}
|
||||
@@ -186,7 +235,6 @@ function hasHailuoImage(req, hasInputReferenceFile) {
|
||||
);
|
||||
}
|
||||
|
||||
const H3_MODEL = "MiniMax-H3";
|
||||
const H3_MIN_DURATION = 4;
|
||||
const H3_MAX_DURATION = 15;
|
||||
const H3_DEFAULT_DURATION = 5;
|
||||
@@ -437,43 +485,106 @@ function responsesVideoText(ctx) {
|
||||
return '<video controls src="' + escaped + '"></video>';
|
||||
}
|
||||
|
||||
function nativeBody(ctx) {
|
||||
if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
|
||||
const body = plainObject(ctx.body.value);
|
||||
if (!body) throw new Error("request body must be an object");
|
||||
return body;
|
||||
}
|
||||
|
||||
// The decoders move the vendor fields to where the OpenAI-style requests carry
|
||||
// them, so buildSubmitRequest sends MiniMax the body the client sent.
|
||||
export const native = {
|
||||
createTask: function (ctx) {
|
||||
const body = nativeBody(ctx);
|
||||
const requestBody = Object.assign(definedFields(body, ["prompt", "duration", "resolution"]), {
|
||||
model: body.model,
|
||||
metadata: definedFields(body, V1_METADATA_FIELDS),
|
||||
});
|
||||
const hasImage = hasHailuoImage(requestBody, false);
|
||||
const duration = requestBody.duration === undefined ? undefined : Number(requestBody.duration);
|
||||
validateHailuoCombo(body.model, duration, outboundResolution(requestBody, body.model), hasImage);
|
||||
return { kind: "submit", model: body.model, action: hasImage ? "image_to_video" : "text_to_video", requestBody: requestBody };
|
||||
},
|
||||
createH3Task: function (ctx) {
|
||||
const body = nativeBody(ctx);
|
||||
if (!Array.isArray(body.content)) throw new Error("content must be an array");
|
||||
const content = validateH3Content(body.content);
|
||||
const requestBody = Object.assign(definedFields(body, ["duration", "resolution"]), {
|
||||
model: body.model,
|
||||
metadata: definedFields(body, ["content", "ratio"].concat(H3_METADATA_FIELDS)),
|
||||
});
|
||||
return { kind: "submit", model: body.model, action: h3HasVisualContent(content) ? "image_to_video" : "text_to_video", requestBody: requestBody };
|
||||
},
|
||||
// MiniMax answers /v1 with a base_resp envelope and /v2 with a bare task_id.
|
||||
taskCreated: function (ctx, task) {
|
||||
return Object.assign({}, plainObject(task.data) || {}, { task_id: task.task_id });
|
||||
},
|
||||
// A status route answers with the latest MiniMax snapshot. Before the first
|
||||
// poll that is the create response, which has no status, and the poller can
|
||||
// fail a task whose snapshot was still running; both take their status from
|
||||
// the task, so a downstream gateway reads the same state.
|
||||
taskStatus: function (ctx, task) {
|
||||
const data = plainObject(task.data) || {};
|
||||
const output = Object.assign({}, data, { task_id: task.task_id });
|
||||
if (task.status === "FAILURE") output.status = "Fail";
|
||||
else if (typeof data.status !== "string") output.status = task.status === "IN_PROGRESS" ? "Processing" : "Queueing";
|
||||
return output;
|
||||
},
|
||||
h3TaskStatus: function (ctx, task) {
|
||||
const data = plainObject(task.data) || {};
|
||||
if (h3APIError(data)) return data;
|
||||
const current = h3QueryTask(data);
|
||||
const output = Object.assign({}, current || {}, { id: task.task_id });
|
||||
if (task.status === "FAILURE" && output.status !== "failed" && output.status !== "cancelled") {
|
||||
output.status = "failed";
|
||||
output.error = { message: task.fail_reason || "task failed" };
|
||||
} else if (!current) {
|
||||
output.status = task.status === "IN_PROGRESS" ? "running" : "queued";
|
||||
}
|
||||
return Object.assign({}, data, { task: output });
|
||||
},
|
||||
// MiniMax reports a rejected call in base_resp on both versions.
|
||||
error: function (ctx, error) {
|
||||
return { base_resp: { status_code: error.httpStatus, status_msg: error.message } };
|
||||
},
|
||||
};
|
||||
|
||||
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];
|
||||
});
|
||||
const h3Body = Object.assign(
|
||||
{
|
||||
model: model,
|
||||
content: content,
|
||||
resolution: h3Resolution(req),
|
||||
duration: h3Duration(req),
|
||||
ratio: h3Ratio(req, content),
|
||||
},
|
||||
definedFields(metadata, H3_METADATA_FIELDS)
|
||||
);
|
||||
return {
|
||||
url: ctx.baseUrl + "/v2/video_generation",
|
||||
url: apiRoot(ctx) + "/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,
|
||||
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];
|
||||
}
|
||||
const body = Object.assign(
|
||||
{
|
||||
model: model,
|
||||
prompt: req.prompt || undefined,
|
||||
duration: outboundDuration(req),
|
||||
resolution: outboundResolution(req, model),
|
||||
},
|
||||
definedFields(metadata, V1_METADATA_FIELDS)
|
||||
);
|
||||
return {
|
||||
url: ctx.baseUrl + "/v1/video_generation",
|
||||
url: apiRoot(ctx) + "/v1/video_generation",
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: "Bearer " + ctx.apiKey },
|
||||
body: body,
|
||||
@@ -524,11 +635,12 @@ export function extractUsage(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);
|
||||
const taskID = encodeURIComponent(ctx.taskId);
|
||||
let path = "/v1/query/video_generation?task_id=" + taskID;
|
||||
if (isH3(ctx.upstreamModel || ctx.model)) path = "/v2/query/video_generation/" + taskID;
|
||||
else if (viaGateway(ctx)) path = "/v1/query/video_generation/" + taskID;
|
||||
return {
|
||||
url: ctx.baseUrl + path,
|
||||
url: apiRoot(ctx) + path,
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json", Authorization: "Bearer " + ctx.apiKey },
|
||||
};
|
||||
@@ -600,11 +712,32 @@ export function buildContentRequest(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,
|
||||
headers: { Accept: "video/*", Authorization: "Bearer " + ctx.apiKey },
|
||||
};
|
||||
// The gateway resolves its MiniMax file itself and serves the video as the
|
||||
// artifact of its own task.
|
||||
if (viaGateway(ctx)) {
|
||||
return {
|
||||
url: ctx.baseUrl + "/v1/tasks/" + encodeURIComponent(ctx.upstreamTaskId) + "/artifacts/video/content",
|
||||
method: ctx.clientRequest.method,
|
||||
headers: { Authorization: "Bearer " + ctx.apiKey },
|
||||
};
|
||||
}
|
||||
// MiniMax serves v1 videos only through the signed download_url that
|
||||
// /v1/files/retrieve returns. The link is signed for GET, so a HEAD client
|
||||
// is served with GET as well; the host sends that client the headers only.
|
||||
const response = utils.fetch({
|
||||
url: ctx.baseUrl + "/v1/files/retrieve?file_id=" + encodeURIComponent(fileID),
|
||||
headers: { Accept: "application/json", Authorization: "Bearer " + ctx.apiKey },
|
||||
});
|
||||
if (!response.body || typeof response.body !== "object") {
|
||||
throw new Error("MiniMax file retrieve returned an unexpected response: HTTP " + response.status);
|
||||
}
|
||||
const base = response.body.base_resp || {};
|
||||
if (response.status !== 200 || base.status_code) {
|
||||
throw new Error("MiniMax file retrieve failed: " + (base.status_msg || "HTTP " + response.status));
|
||||
}
|
||||
const url = trimmed(response.body.file && response.body.file.download_url);
|
||||
if (!url) throw new Error("artifact_not_found");
|
||||
return { url: url, method: "GET", credentialless: true };
|
||||
}
|
||||
|
||||
export function extractUsageOnComplete(_task, _taskResult, body) {
|
||||
|
||||
@@ -7,11 +7,12 @@ export const meta = {
|
||||
en: "Volcengine Jimeng video generation (text-to-video, image-to-video, and first-and-last-frame)",
|
||||
zh: "火山引擎即梦视频生成(文生视频、图生视频、首尾帧)",
|
||||
},
|
||||
version: "1.0.2",
|
||||
version: "1.1.0",
|
||||
author: { name: "QuantumNous" },
|
||||
channelTypes: [51],
|
||||
models: ["jimeng_vgfm_t2v_l20"],
|
||||
fetchMode: "per_task",
|
||||
upstreams: ["vendor", "new_api"],
|
||||
usageSchema: {
|
||||
// Requested video duration in seconds. S2.0 Pro is fixed at 5; 3.0 req_keys allow 5 or 10.
|
||||
seconds: {
|
||||
@@ -90,6 +91,12 @@ function isRelay(apiKey) {
|
||||
return apiKey.startsWith("sk-");
|
||||
}
|
||||
|
||||
// The host signal is authoritative on New API channels; the sk- key prefix
|
||||
// stays as the heuristic for legacy type-51 channels pointed at a gateway.
|
||||
function viaGateway(ctx) {
|
||||
return !!(ctx.upstream && ctx.upstream.kind === "new_api") || isRelay(ctx.apiKey);
|
||||
}
|
||||
|
||||
function imageValues(body) {
|
||||
const images = [];
|
||||
if (Array.isArray(body.image_urls)) {
|
||||
@@ -153,13 +160,13 @@ function decodeNativeRequest(ctx) {
|
||||
};
|
||||
}
|
||||
|
||||
function endpoint(baseUrl, apiKey, action) {
|
||||
return baseUrl + (isRelay(apiKey) ? "/jimeng/" : "/") + "?Action=" + action + "&Version=2022-08-31";
|
||||
function endpoint(ctx, action) {
|
||||
return ctx.baseUrl + (viaGateway(ctx) ? "/jimeng/" : "/") + "?Action=" + action + "&Version=2022-08-31";
|
||||
}
|
||||
|
||||
function requestHeaders(ctx, method, url, bodyText) {
|
||||
const headers = { "Content-Type": "application/json", Accept: "application/json" };
|
||||
if (isRelay(ctx.apiKey)) {
|
||||
if (viaGateway(ctx)) {
|
||||
headers.Authorization = "Bearer " + ctx.apiKey;
|
||||
return headers;
|
||||
}
|
||||
@@ -337,7 +344,7 @@ export function buildSubmitRequest(ctx) {
|
||||
ordered.seed = body.seed;
|
||||
if (body.aspect_ratio) ordered.aspect_ratio = body.aspect_ratio;
|
||||
if (body.frames) ordered.frames = body.frames;
|
||||
const url = endpoint(ctx.baseUrl, ctx.apiKey, "CVSync2AsyncSubmitTask");
|
||||
const url = endpoint(ctx, "CVSync2AsyncSubmitTask");
|
||||
const bodyText = JSON.stringify(ordered);
|
||||
return {
|
||||
url: url,
|
||||
@@ -363,7 +370,7 @@ export function extractUsage(ctx) {
|
||||
|
||||
export function buildQueryRequest(ctx) {
|
||||
const body = JSON.stringify({ req_key: queryReqKey(ctx), task_id: ctx.taskId });
|
||||
const url = endpoint(ctx.baseUrl, ctx.apiKey, "CVSync2AsyncGetResult");
|
||||
const url = endpoint(ctx, "CVSync2AsyncGetResult");
|
||||
return { url: url, method: "POST", headers: requestHeaders(ctx, "POST", url, body), body: body };
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,12 @@ export const meta = {
|
||||
en: "Kuaishou Kling video generation (text-to-video and image-to-video)",
|
||||
zh: "快手可灵视频生成(文生视频、图生视频)",
|
||||
},
|
||||
version: "1.0.2",
|
||||
version: "1.1.0",
|
||||
author: { name: "QuantumNous" },
|
||||
channelTypes: [50],
|
||||
models: ["kling-v1", "kling-v1-6", "kling-v2-master"],
|
||||
fetchMode: "per_task",
|
||||
upstreams: ["vendor", "new_api"],
|
||||
usageSchema: {
|
||||
// Kling final unit deduction (estimated at submit, actual on completion).
|
||||
units: {
|
||||
@@ -98,9 +99,15 @@ function isRelay(apiKey) {
|
||||
return apiKey.startsWith("sk-");
|
||||
}
|
||||
|
||||
function tokenFor(apiKey) {
|
||||
if (isRelay(apiKey)) return apiKey;
|
||||
const parts = apiKey.split("|");
|
||||
// The host signal is authoritative on New API channels; the sk- key prefix
|
||||
// stays as the heuristic for legacy type-50 channels pointed at a gateway.
|
||||
function viaGateway(ctx) {
|
||||
return !!(ctx.upstream && ctx.upstream.kind === "new_api") || isRelay(ctx.apiKey);
|
||||
}
|
||||
|
||||
function tokenFor(ctx) {
|
||||
if (viaGateway(ctx)) return ctx.apiKey;
|
||||
const parts = ctx.apiKey.split("|");
|
||||
if (parts.length !== 2) throw new Error("invalid api_key, required format is accessKey|secretKey");
|
||||
const now = utils.unixNow();
|
||||
return utils.jwtSignHS256({ iss: parts[0].trim(), exp: now + 1800, nbf: now - 5 }, parts[1].trim());
|
||||
@@ -110,8 +117,8 @@ function pathFor(action) {
|
||||
return action === "image_to_video" ? "/v1/videos/image2video" : "/v1/videos/text2video";
|
||||
}
|
||||
|
||||
function urlFor(baseUrl, apiKey, action) {
|
||||
return baseUrl + (isRelay(apiKey) ? "/kling" : "") + pathFor(action);
|
||||
function urlFor(ctx, action) {
|
||||
return ctx.baseUrl + (viaGateway(ctx) ? "/kling" : "") + pathFor(action);
|
||||
}
|
||||
|
||||
function aspectRatio(size) {
|
||||
@@ -248,9 +255,9 @@ export function buildSubmitRequest(ctx) {
|
||||
if (!body.prompt) delete body.prompt;
|
||||
if (!body.image) delete body.image;
|
||||
return {
|
||||
url: urlFor(ctx.baseUrl, ctx.apiKey, action),
|
||||
url: urlFor(ctx, action),
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: "Bearer " + tokenFor(ctx.apiKey), "User-Agent": "kling-sdk/1.0" },
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: "Bearer " + tokenFor(ctx), "User-Agent": "kling-sdk/1.0" },
|
||||
body: body,
|
||||
action: action,
|
||||
};
|
||||
@@ -274,9 +281,9 @@ export function extractUsage(ctx) {
|
||||
|
||||
export function buildQueryRequest(ctx) {
|
||||
return {
|
||||
url: urlFor(ctx.baseUrl, ctx.apiKey, ctx.action) + "/" + ctx.taskId,
|
||||
url: urlFor(ctx, ctx.action) + "/" + ctx.taskId,
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json", Authorization: "Bearer " + tokenFor(ctx.apiKey), "User-Agent": "kling-sdk/1.0" },
|
||||
headers: { Accept: "application/json", Authorization: "Bearer " + tokenFor(ctx), "User-Agent": "kling-sdk/1.0" },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,13 @@ export const meta = {
|
||||
en: "OpenAI Sora video generation (text-to-video, image-to-video, and remix)",
|
||||
zh: "OpenAI Sora 视频生成(文生视频、图生视频、remix)",
|
||||
},
|
||||
version: "1.0.3",
|
||||
version: "1.1.0",
|
||||
channelTypes: [55, 1], // OpenAI-type channels natively serve sora with the same wire format
|
||||
author: { name: "QuantumNous" },
|
||||
models: ["sora-2", "sora-2-pro"],
|
||||
fetchMode: "per_task",
|
||||
// A New API gateway serves /v1/videos as a host protocol, so no URL changes.
|
||||
upstreams: ["vendor", "new_api"],
|
||||
usageSchema: {
|
||||
// Requested video duration in seconds.
|
||||
seconds: {
|
||||
|
||||
@@ -9,11 +9,13 @@ export const meta = {
|
||||
en: "SunoAPI project music and lyrics generation",
|
||||
zh: "SunoAPI 项目 音乐与歌词生成",
|
||||
},
|
||||
version: "1.0.3",
|
||||
version: "1.1.0",
|
||||
author: { name: "QuantumNous" },
|
||||
channelTypes: [36],
|
||||
models: ["suno_music", "suno_lyrics"],
|
||||
fetchMode: "batch",
|
||||
// Vendor paths already equal the /suno native routes, so no URL changes.
|
||||
upstreams: ["vendor", "new_api"],
|
||||
usageSchema: {
|
||||
// Number of generated music or lyrics clips.
|
||||
clips: {
|
||||
|
||||
@@ -7,12 +7,13 @@ export const meta = {
|
||||
en: "Google Veo video generation on Vertex AI (text-to-video and image-to-video)",
|
||||
zh: "Google Veo 视频生成(文生视频、图生视频),Vertex AI 版本",
|
||||
},
|
||||
version: "1.0.2",
|
||||
version: "1.1.0",
|
||||
channelTypes: [41],
|
||||
author: { name: "QuantumNous" },
|
||||
models: ["veo-3.0-generate-001", "veo-3.0-fast-generate-001", "veo-3.1-generate-preview", "veo-3.1-fast-generate-preview"],
|
||||
fetchMode: "per_task",
|
||||
auth: { type: "oauth2_jwt" },
|
||||
upstreams: ["vendor", "new_api"],
|
||||
usageSchema: {
|
||||
// Requested video duration in seconds. Allowed values: 4, 6, 8.
|
||||
seconds: {
|
||||
@@ -38,12 +39,40 @@ export const meta = {
|
||||
{ label: "8s 1080p audio", facts: { seconds: 8, resolution: "1080p", generate_audio: true } },
|
||||
{ label: "8s 4k audio", facts: { seconds: 8, resolution: "4k", generate_audio: true } },
|
||||
],
|
||||
// Vertex AI request and response bodies, for clients and for a downstream New
|
||||
// API gateway. The model segment carries the method: predictLongRunning
|
||||
// submits and fetchPredictOperation polls. The gateway runs every request in
|
||||
// its own channel's project; the operation names it returns echo the path's
|
||||
// project and location and end with the gateway task id.
|
||||
routes: [
|
||||
{
|
||||
method: "POST",
|
||||
path: "/vertex-ai/v1/projects/:project/locations/:location/publishers/google/models/:model",
|
||||
type: "dynamic",
|
||||
decode: "modelMethod",
|
||||
render: "operation",
|
||||
},
|
||||
],
|
||||
protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
|
||||
};
|
||||
|
||||
function trimmed(value) {
|
||||
return String(value || "").trim();
|
||||
}
|
||||
function plainObject(value) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
||||
}
|
||||
// On a New API channel the same plugin answers on another gateway, which
|
||||
// serves the Vertex AI wire format on the prefixed native routes, takes its own
|
||||
// token as a Bearer header and runs the request in its own project.
|
||||
function viaGateway(ctx) {
|
||||
return Boolean(ctx.upstream && ctx.upstream.kind === "new_api");
|
||||
}
|
||||
function requestHeaders(ctx) {
|
||||
const headers = { "Content-Type": "application/json", Accept: "application/json", Authorization: ctx.authHeader };
|
||||
if (!viaGateway(ctx)) headers["x-goog-user-project"] = ctx.auth.projectId;
|
||||
return headers;
|
||||
}
|
||||
function responsesInput(req) {
|
||||
const texts = [],
|
||||
images = [];
|
||||
@@ -125,7 +154,7 @@ function apiBase(baseUrl, project, location) {
|
||||
return "https://" + (location === "global" ? "" : location + "-") + "aiplatform.googleapis.com/v1/projects/" + project + "/locations/" + location;
|
||||
}
|
||||
function modelURL(ctx, project, location, model, action) {
|
||||
return apiBase(ctx.baseUrl, project, location) + "/publishers/google/models/" + model + ":" + action;
|
||||
return apiBase(ctx.baseUrl + (viaGateway(ctx) ? "/vertex-ai" : ""), project, location) + "/publishers/google/models/" + model + ":" + action;
|
||||
}
|
||||
function decodeTaskId(value) {
|
||||
return utils.base64URLDecode(value);
|
||||
@@ -144,6 +173,71 @@ function dataVideo(response) {
|
||||
return "data:" + (String(enc).includes("/") ? enc : "video/" + enc) + ";base64," + data;
|
||||
}
|
||||
|
||||
export const native = {
|
||||
modelMethod: function (ctx) {
|
||||
const segment = String((ctx.params || {}).model || "");
|
||||
const separator = segment.lastIndexOf(":");
|
||||
const method = separator > 0 ? segment.slice(separator + 1) : "";
|
||||
if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
|
||||
const body = plainObject(ctx.body.value);
|
||||
if (!body) throw new Error("request body must be an object");
|
||||
if (method === "fetchPredictOperation") {
|
||||
const name = String(body.operationName || "");
|
||||
const marker = name.lastIndexOf("/operations/");
|
||||
if (marker < 0) throw new Error("operationName must name an operation");
|
||||
return { kind: "query", taskIds: [name.slice(marker + "/operations/".length)] };
|
||||
}
|
||||
if (method !== "predictLongRunning") throw new Error("the path must name models/{model}:predictLongRunning or :fetchPredictOperation");
|
||||
const model = segment.slice(0, separator);
|
||||
const instances = Array.isArray(body.instances) ? body.instances : [];
|
||||
const instance = plainObject(instances[0]);
|
||||
if (instances.length !== 1 || !instance) throw new Error("instances must hold exactly one instance");
|
||||
// buildSubmitRequest sends only the prompt and the first frame.
|
||||
for (const key of Object.keys(instance)) {
|
||||
if (key !== "prompt" && key !== "image") throw new Error("instances[0]." + key + " is not supported");
|
||||
}
|
||||
const metadata = Object.assign({}, plainObject(body.parameters) || {});
|
||||
const requestBody = { model: model, prompt: instance.prompt, metadata: metadata };
|
||||
if (metadata.durationSeconds !== undefined) {
|
||||
requestBody.duration = metadata.durationSeconds;
|
||||
delete metadata.durationSeconds;
|
||||
if ([4, 6, 8].indexOf(Number(requestBody.duration)) < 0) throw new Error("durationSeconds must be one of 4, 6, or 8");
|
||||
}
|
||||
if (metadata.resolution !== undefined && ["720p", "1080p", "4k"].indexOf(String(metadata.resolution).toLowerCase()) < 0)
|
||||
throw new Error("resolution must be one of 720p, 1080p, or 4k");
|
||||
if (instance.image !== undefined) {
|
||||
const image = plainObject(instance.image) || {};
|
||||
const mime = trimmed(image.mimeType).toLowerCase();
|
||||
if (!trimmed(image.bytesBase64Encoded)) throw new Error("instances[0].image must carry bytesBase64Encoded");
|
||||
if (mime !== "image/jpeg" && mime !== "image/png") throw new Error("input image must be image/jpeg or image/png");
|
||||
requestBody.images = ["data:" + mime + ";base64," + image.bytesBase64Encoded];
|
||||
}
|
||||
return { kind: "submit", model: model, action: requestBody.images ? "image_to_video" : "text_to_video", requestBody: requestBody };
|
||||
},
|
||||
// Submission renders one task and fetchPredictOperation a list of one. The
|
||||
// poller can fail a task whose operation was still running; that operation
|
||||
// is rendered done with the failure.
|
||||
operation: function (ctx, tasks) {
|
||||
const task = Array.isArray(tasks) ? tasks[0] : tasks;
|
||||
const params = ctx.params || {};
|
||||
const data = plainObject(task.data) || {};
|
||||
const named = /\/models\/([^/]+)\/operations\//.exec(String(data.name || ""));
|
||||
const model = named ? named[1] : String(params.model || "").split(":")[0];
|
||||
const output = Object.assign({}, data, {
|
||||
name: "projects/" + params.project + "/locations/" + params.location + "/publishers/google/models/" + model + "/operations/" + task.task_id,
|
||||
});
|
||||
if (task.status === "FAILURE" && !(plainObject(data.error) && data.error.message)) {
|
||||
output.done = true;
|
||||
output.error = { message: task.fail_reason || "task failed" };
|
||||
delete output.response;
|
||||
}
|
||||
return output;
|
||||
},
|
||||
error: function (ctx, error) {
|
||||
return { error: { code: error.httpStatus, message: error.message } };
|
||||
},
|
||||
};
|
||||
|
||||
export function buildSubmitRequest(ctx) {
|
||||
if (ctx.authError) throw new Error(ctx.authError);
|
||||
const req = ctx.requestBody || {},
|
||||
@@ -183,9 +277,10 @@ export function buildSubmitRequest(ctx) {
|
||||
instance.image = { bytesBase64Encoded: data, mimeType: mime };
|
||||
}
|
||||
return {
|
||||
url: modelURL(ctx, ctx.auth.projectId, region(ctx, model), model, "predictLongRunning"),
|
||||
// A gateway runs the request in its own project, so the path names none.
|
||||
url: modelURL(ctx, viaGateway(ctx) ? "-" : ctx.auth.projectId, region(ctx, model), model, "predictLongRunning"),
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: ctx.authHeader, "x-goog-user-project": ctx.auth.projectId },
|
||||
headers: requestHeaders(ctx),
|
||||
body: { instances: [instance], parameters: metadata },
|
||||
action: image ? "image_to_video" : "text_to_video",
|
||||
};
|
||||
@@ -217,7 +312,7 @@ export function buildQueryRequest(ctx) {
|
||||
return {
|
||||
url: modelURL(ctx, project, location || "us-central1", model, "fetchPredictOperation"),
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: ctx.authHeader, "x-goog-user-project": ctx.auth.projectId },
|
||||
headers: requestHeaders(ctx),
|
||||
body: { operationName: name },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,11 +7,12 @@ export const meta = {
|
||||
en: "Shengshu Vidu video generation (text-to-video, image-to-video, first-and-last-frame, and reference-to-video)",
|
||||
zh: "生数 Vidu 视频生成(文生视频、图生视频、首尾帧、参考生视频)",
|
||||
},
|
||||
version: "1.0.2",
|
||||
version: "1.1.0",
|
||||
author: { name: "QuantumNous" },
|
||||
channelTypes: [52],
|
||||
models: ["viduq2", "viduq1", "vidu2.0", "vidu1.5"],
|
||||
fetchMode: "per_task",
|
||||
upstreams: ["vendor", "new_api"],
|
||||
usageSchema: {
|
||||
// estimated/actual Vidu credits
|
||||
credits: {
|
||||
@@ -46,15 +47,52 @@ export const meta = {
|
||||
{ label: "2.0 4s 720p", facts: { credits: 0, duration: 4, resolution: "720p" } },
|
||||
{ label: "2.0 8s 720p", facts: { credits: 0, duration: 8, resolution: "720p" } },
|
||||
],
|
||||
// Vidu request and response bodies, for clients and for a downstream New API
|
||||
// gateway.
|
||||
routes: [
|
||||
{ method: "POST", path: "/vidu/ent/v2/text2video", type: "submit", decode: "createTask", render: "taskCreated" },
|
||||
{ method: "POST", path: "/vidu/ent/v2/img2video", type: "submit", decode: "createTask", render: "taskCreated" },
|
||||
{ method: "POST", path: "/vidu/ent/v2/start-end2video", type: "submit", decode: "createTask", render: "taskCreated" },
|
||||
{ method: "POST", path: "/vidu/ent/v2/reference2video", type: "submit", decode: "createTask", render: "taskCreated" },
|
||||
{ method: "GET", path: "/vidu/ent/v2/tasks/:task_id/creations", type: "query", render: "taskStatus" },
|
||||
],
|
||||
protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
|
||||
};
|
||||
|
||||
const RESOLUTIONS = ["360p", "540p", "720p", "1080p"];
|
||||
|
||||
// The submit route names the action; one or two reference images would
|
||||
// otherwise read as image-to-video or first-and-last-frame.
|
||||
const ROUTE_ACTIONS = {
|
||||
text2video: "text_to_video",
|
||||
img2video: "image_to_video",
|
||||
"start-end2video": "first_tail_to_video",
|
||||
reference2video: "reference_to_video",
|
||||
};
|
||||
|
||||
function trimmed(value) {
|
||||
return String(value || "").trim();
|
||||
}
|
||||
|
||||
function plainObject(value) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
||||
}
|
||||
|
||||
// On a New API channel the same plugin answers on another gateway, which
|
||||
// serves the Vidu wire format on the prefixed native routes and takes its own
|
||||
// token as a Bearer header.
|
||||
function viaGateway(ctx) {
|
||||
return Boolean(ctx.upstream && ctx.upstream.kind === "new_api");
|
||||
}
|
||||
|
||||
function apiRoot(ctx) {
|
||||
return ctx.baseUrl + (viaGateway(ctx) ? "/vidu" : "") + "/ent/v2";
|
||||
}
|
||||
|
||||
function authorization(ctx) {
|
||||
return (viaGateway(ctx) ? "Bearer " : "Token ") + ctx.apiKey;
|
||||
}
|
||||
|
||||
function isQ2Model(model) {
|
||||
return String(model || "").indexOf("viduq2") === 0;
|
||||
}
|
||||
@@ -219,6 +257,45 @@ function pathFor(action) {
|
||||
return "/text2video";
|
||||
}
|
||||
|
||||
export const native = {
|
||||
createTask: function (ctx) {
|
||||
if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
|
||||
const body = plainObject(ctx.body.value);
|
||||
if (!body) throw new Error("request body must be an object");
|
||||
const model = trimmed(body.model);
|
||||
if (!model) throw new Error("model is required");
|
||||
const action = ROUTE_ACTIONS[ctx.path.slice(ctx.path.lastIndexOf("/") + 1)];
|
||||
// The other Vidu fields ride in metadata, which buildSubmitRequest
|
||||
// spreads back into the body.
|
||||
const metadata = Object.assign({}, body, { action: action });
|
||||
const requestBody = { model: model, metadata: metadata };
|
||||
delete metadata.model;
|
||||
for (const key of ["images", "prompt", "duration", "resolution"]) {
|
||||
delete metadata[key];
|
||||
if (body[key] !== undefined && body[key] !== null) requestBody[key] = body[key];
|
||||
}
|
||||
const duration = requestBody.duration === undefined ? undefined : Number(requestBody.duration);
|
||||
validateViduCombo(model, duration, outboundResolution(requestBody, model), hasViduImages(requestBody, false));
|
||||
return { kind: "submit", model: model, action: action, requestBody: requestBody };
|
||||
},
|
||||
taskCreated: function (ctx, task) {
|
||||
return Object.assign({}, plainObject(task.data) || {}, { task_id: task.task_id });
|
||||
},
|
||||
// The poller can fail a task whose snapshot was still processing; that
|
||||
// snapshot is rendered failed so a downstream gateway reads the same state.
|
||||
taskStatus: function (ctx, task) {
|
||||
const output = Object.assign({}, plainObject(task.data) || {}, { id: task.task_id });
|
||||
if (task.status === "FAILURE" && output.state !== "failed") {
|
||||
output.state = "failed";
|
||||
output.err_code = output.err_code || task.fail_reason || "task failed";
|
||||
}
|
||||
return output;
|
||||
},
|
||||
error: function (ctx, error) {
|
||||
return { code: error.code, message: error.message };
|
||||
},
|
||||
};
|
||||
|
||||
export function buildSubmitRequest(ctx) {
|
||||
const req = ctx.requestBody;
|
||||
const action = actionFor(req);
|
||||
@@ -226,25 +303,21 @@ export function buildSubmitRequest(ctx) {
|
||||
let model = ctx.upstreamModel || "viduq1";
|
||||
if (action === "reference_to_video" && model.includes("viduq2")) model = "viduq2";
|
||||
const images = Array.isArray(req.images) ? req.images.map(filePlaceholder) : null;
|
||||
const body = Object.assign(
|
||||
{
|
||||
model: model,
|
||||
images: images,
|
||||
prompt: req.prompt || null,
|
||||
duration: outboundDuration(req, model),
|
||||
resolution: outboundResolution(req, model),
|
||||
movement_amplitude: "auto",
|
||||
},
|
||||
metadata
|
||||
);
|
||||
// metadata carries the other Vidu fields and cannot replace the billed
|
||||
// model, duration and resolution.
|
||||
const body = Object.assign({ images: images, prompt: req.prompt || null, movement_amplitude: "auto" }, metadata, {
|
||||
model: model,
|
||||
duration: outboundDuration(req, model),
|
||||
resolution: outboundResolution(req, model),
|
||||
});
|
||||
delete body.action;
|
||||
if (!body.prompt) delete body.prompt;
|
||||
if (!body.bgm) delete body.bgm;
|
||||
if (Array.isArray(body.images)) body.images = body.images.map(filePlaceholder);
|
||||
return {
|
||||
url: ctx.baseUrl + "/ent/v2" + pathFor(action),
|
||||
url: apiRoot(ctx) + pathFor(action),
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: "Token " + ctx.apiKey },
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: authorization(ctx) },
|
||||
body: body,
|
||||
action: action,
|
||||
};
|
||||
@@ -265,9 +338,9 @@ export function extractUsage(ctx) {
|
||||
|
||||
export function buildQueryRequest(ctx) {
|
||||
return {
|
||||
url: ctx.baseUrl + "/ent/v2/tasks/" + ctx.taskId + "/creations",
|
||||
url: apiRoot(ctx) + "/tasks/" + ctx.taskId + "/creations",
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json", Authorization: "Token " + ctx.apiKey },
|
||||
headers: { Accept: "application/json", Authorization: authorization(ctx) },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,783 @@
|
||||
// xAI bills each output second by model and resolution, each input image, and
|
||||
// on grok-imagine-video each input video second of an edit or extension
|
||||
// (docs.x.ai/developers/models/<model>).
|
||||
const VIDEO_MODELS = {
|
||||
// The only model that takes video input, so edits and extensions use it.
|
||||
"grok-imagine-video": { resolutions: ["480p", "720p"], videoInput: true },
|
||||
"grok-imagine-video-1.5": { resolutions: ["480p", "720p", "1080p"] },
|
||||
"grok-imagine-video-1.5-lite": { resolutions: ["480p", "720p", "1080p"] },
|
||||
};
|
||||
|
||||
const ASPECT_RATIOS = ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3"];
|
||||
const DEFAULT_RESOLUTION = "480p";
|
||||
const DEFAULT_DURATION = 8;
|
||||
const MAX_DURATION = 15;
|
||||
const DEFAULT_EXTENSION_SECONDS = 6;
|
||||
const MIN_EXTENSION_SECONDS = 2;
|
||||
const MAX_EXTENSION_SECONDS = 10;
|
||||
// Edits accept at most 8.7 seconds of input and return the same length.
|
||||
const MAX_EDIT_SECONDS = 9;
|
||||
// Extensions accept 2 to 15 seconds of input and return input plus extension.
|
||||
const MAX_EXTENSION_INPUT_SECONDS = 15;
|
||||
const MAX_REFERENCE_IMAGES = 7;
|
||||
const MAX_KEYFRAMES = 4;
|
||||
const MAX_REFERENCE_AUDIOS = 3;
|
||||
// Edits and extensions keep the input video's resolution, capped at 720p,
|
||||
// which neither the request nor the result reports: the cap is reserved and
|
||||
// the output file's MP4 header settles the resolution.
|
||||
const EDIT_RESOLUTION_CAP = "720p";
|
||||
|
||||
const SECONDS_FIELD = {
|
||||
type: "number",
|
||||
unit: "second",
|
||||
description: { en: "Video generation unit price", zh: "视频生成单价" },
|
||||
};
|
||||
const INPUT_IMAGES_FIELD = {
|
||||
type: "number",
|
||||
unit: "count",
|
||||
unitLabel: { en: "image", zh: "张", "zh-TW": "張" },
|
||||
description: { en: "Input image unit price", zh: "输入图片单价" },
|
||||
};
|
||||
const INPUT_VIDEO_SECONDS_FIELD = {
|
||||
type: "number",
|
||||
unit: "second",
|
||||
description: { en: "Input video unit price", zh: "输入视频单价" },
|
||||
};
|
||||
// xAI still bills a request its moderation blocks ("Usage Guidelines
|
||||
// Violation Fee", docs.x.ai/developers/pricing), so such a task settles as
|
||||
// charged instead of refunded; this fact lets its price differ from a
|
||||
// delivered video.
|
||||
const MODERATED_FIELD = {
|
||||
type: "boolean",
|
||||
description: { en: "Blocked by content moderation", zh: "被内容审核拦截" },
|
||||
};
|
||||
const RESOLUTION_DESCRIPTION = { en: "Output video resolution", zh: "输出视频分辨率" };
|
||||
|
||||
const GROK_IMAGINE_VIDEO_USAGE_SCHEMA = {
|
||||
seconds: SECONDS_FIELD,
|
||||
resolution: { enum: VIDEO_MODELS["grok-imagine-video"].resolutions, description: RESOLUTION_DESCRIPTION },
|
||||
input_images: INPUT_IMAGES_FIELD,
|
||||
input_video_seconds: INPUT_VIDEO_SECONDS_FIELD,
|
||||
moderated: MODERATED_FIELD,
|
||||
};
|
||||
|
||||
const GROK_IMAGINE_VIDEO_1_5_USAGE_SCHEMA = {
|
||||
seconds: SECONDS_FIELD,
|
||||
resolution: { enum: VIDEO_MODELS["grok-imagine-video-1.5"].resolutions, description: RESOLUTION_DESCRIPTION },
|
||||
input_images: INPUT_IMAGES_FIELD,
|
||||
moderated: MODERATED_FIELD,
|
||||
};
|
||||
|
||||
export const meta = {
|
||||
apiVersion: 1,
|
||||
key: "xai",
|
||||
name: "xAI",
|
||||
icon: "XAI",
|
||||
description: {
|
||||
en: "xAI Grok Imagine video generation (text-to-video, image-to-video, reference-to-video, video editing, and video extension)",
|
||||
zh: "xAI Grok Imagine 视频生成(文生视频、图生视频、参考生视频、视频编辑、视频延长)",
|
||||
},
|
||||
version: "1.1.0",
|
||||
author: { name: "QuantumNous" },
|
||||
channelTypes: [48], // xAI-type channels serve the video API with the same key
|
||||
baseUrl: "https://api.x.ai",
|
||||
// Result videos, whose headers settle the resolution of edits and extensions.
|
||||
allowedHosts: ["vidgen.x.ai"],
|
||||
models: Object.keys(VIDEO_MODELS),
|
||||
fetchMode: "per_task",
|
||||
upstreams: ["vendor", "new_api"],
|
||||
// Fallback for a channel alias that resolves to more than one model: the
|
||||
// union of every model's fields and resolutions.
|
||||
usageSchema: Object.assign({}, GROK_IMAGINE_VIDEO_USAGE_SCHEMA, {
|
||||
resolution: { enum: ["480p", "720p", "1080p"], description: RESOLUTION_DESCRIPTION },
|
||||
}),
|
||||
usageExamples: [
|
||||
{ label: "1.5 480p 8s", facts: { seconds: 8, resolution: "480p", input_images: 0, input_video_seconds: 0, moderated: false } },
|
||||
{ label: "1.5 1080p 8s · image", facts: { seconds: 8, resolution: "1080p", input_images: 1, input_video_seconds: 0, moderated: false } },
|
||||
{ label: "edit 8s · 720p", facts: { seconds: 8, resolution: "720p", input_images: 0, input_video_seconds: 8, moderated: false } },
|
||||
{ label: "480p 8s · blocked", facts: { seconds: 8, resolution: "480p", input_images: 0, input_video_seconds: 0, moderated: true } },
|
||||
],
|
||||
usageProfiles: [
|
||||
{
|
||||
models: ["grok-imagine-video"],
|
||||
schema: GROK_IMAGINE_VIDEO_USAGE_SCHEMA,
|
||||
examples: [
|
||||
{ label: "480p 8s", facts: { seconds: 8, resolution: "480p", input_images: 0, input_video_seconds: 0, moderated: false } },
|
||||
{ label: "720p 8s · image", facts: { seconds: 8, resolution: "720p", input_images: 1, input_video_seconds: 0, moderated: false } },
|
||||
{ label: "edit 8s · 720p", facts: { seconds: 8, resolution: "720p", input_images: 0, input_video_seconds: 8, moderated: false } },
|
||||
{ label: "extend 8s + 6s · 480p", facts: { seconds: 6, resolution: "480p", input_images: 0, input_video_seconds: 8, moderated: false } },
|
||||
{ label: "480p 8s · blocked", facts: { seconds: 8, resolution: "480p", input_images: 0, input_video_seconds: 0, moderated: true } },
|
||||
],
|
||||
},
|
||||
{
|
||||
models: ["grok-imagine-video-1.5", "grok-imagine-video-1.5-lite"],
|
||||
schema: GROK_IMAGINE_VIDEO_1_5_USAGE_SCHEMA,
|
||||
examples: [
|
||||
{ label: "480p 8s", facts: { seconds: 8, resolution: "480p", input_images: 0, moderated: false } },
|
||||
{ label: "720p 8s · image", facts: { seconds: 8, resolution: "720p", input_images: 1, moderated: false } },
|
||||
{ label: "1080p 15s", facts: { seconds: 15, resolution: "1080p", input_images: 0, moderated: false } },
|
||||
{ label: "480p 8s · blocked", facts: { seconds: 8, resolution: "480p", input_images: 0, moderated: true } },
|
||||
],
|
||||
},
|
||||
],
|
||||
routes: [
|
||||
{ method: "POST", path: "/xai/v1/videos/generations", type: "submit", decode: "createGeneration", render: "videoCreated" },
|
||||
{ method: "POST", path: "/xai/v1/videos/edits", type: "submit", decode: "createEdit", render: "videoCreated", models: ["grok-imagine-video"] },
|
||||
{ method: "POST", path: "/xai/v1/videos/extensions", type: "submit", decode: "createExtension", render: "videoCreated", models: ["grok-imagine-video"] },
|
||||
{ method: "GET", path: "/xai/v1/videos/:request_id", type: "query", taskIdParam: "request_id", render: "videoStatus" },
|
||||
],
|
||||
protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
|
||||
};
|
||||
|
||||
function trimmed(value) {
|
||||
return String(value || "").trim();
|
||||
}
|
||||
|
||||
function plainObject(value) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
||||
}
|
||||
|
||||
// Another New API gateway serves the xAI wire format only on this plugin's
|
||||
// prefixed native routes.
|
||||
function apiRoot(ctx) {
|
||||
return ctx.baseUrl + (ctx.upstream && ctx.upstream.kind === "new_api" ? "/xai" : "");
|
||||
}
|
||||
|
||||
function videoModel(identity) {
|
||||
const names = [identity.upstreamModel, identity.model].map(trimmed).filter(Boolean);
|
||||
for (const name of names) {
|
||||
if (VIDEO_MODELS[name]) return name;
|
||||
}
|
||||
throw new Error((names[0] || "model") + " is not a supported xAI video model");
|
||||
}
|
||||
|
||||
function integerField(value, name, minimum, maximum) {
|
||||
const number = typeof value === "string" && value.trim() !== "" ? Number(value) : value;
|
||||
if (typeof number !== "number" || !Number.isInteger(number) || number < minimum || number > maximum)
|
||||
throw new Error(name + " must be an integer between " + minimum + " and " + maximum);
|
||||
return number;
|
||||
}
|
||||
|
||||
// Media inputs are URLs, base64 data URLs, or host file placeholders. A
|
||||
// file_id would name a file in the gateway's own xAI account, which callers
|
||||
// must not reach.
|
||||
function mediaInput(value, name) {
|
||||
const object = plainObject(value);
|
||||
if (object && object.file_id !== undefined) throw new Error(name + " must be a URL; file_id is not supported");
|
||||
const url = object ? (object.url !== undefined ? object.url : object.image_url) : value;
|
||||
if (plainObject(url) && url.__fileRef) return { url: url };
|
||||
if (typeof url === "string" && url.trim()) return { url: url.trim() };
|
||||
throw new Error(name + " must be a URL or {url}");
|
||||
}
|
||||
|
||||
function promptField(source, required) {
|
||||
if (source.prompt !== undefined && source.prompt !== null && typeof source.prompt !== "string") throw new Error("prompt must be a string");
|
||||
const prompt = trimmed(source.prompt);
|
||||
if (required && !prompt) throw new Error("prompt is required");
|
||||
return prompt;
|
||||
}
|
||||
|
||||
// Fields every operation accepts. storage_options is never forwarded: it
|
||||
// stores the output in the gateway's xAI account.
|
||||
function commonFields(source, body) {
|
||||
if (source.user !== undefined && source.user !== null) {
|
||||
if (typeof source.user !== "string") throw new Error("user must be a string");
|
||||
body.user = source.user;
|
||||
}
|
||||
if (source.output !== undefined && source.output !== null) {
|
||||
const output = plainObject(source.output);
|
||||
if (!output || typeof output.upload_url !== "string" || !output.upload_url.trim()) throw new Error("output.upload_url must be a URL");
|
||||
body.output = { upload_url: output.upload_url.trim() };
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
// Validates a /v1/videos/generations request and keeps only documented
|
||||
// fields, so an unlisted field cannot carry a billable quantity past these
|
||||
// checks. Duration and resolution are always sent so the reserved charge
|
||||
// matches what xAI renders even if its defaults change.
|
||||
function generationRequest(source, identity) {
|
||||
const model = videoModel(identity);
|
||||
const image = source.image === undefined || source.image === null ? null : mediaInput(source.image, "image");
|
||||
if (source.reference_images !== undefined && source.reference_images !== null && !Array.isArray(source.reference_images))
|
||||
throw new Error("reference_images must be an array");
|
||||
if ((source.reference_images || []).length > MAX_REFERENCE_IMAGES) throw new Error("reference_images accepts at most " + MAX_REFERENCE_IMAGES + " items");
|
||||
const referenceImages = (source.reference_images || []).map(function (item, index) {
|
||||
return mediaInput(item, "reference_images[" + index + "]");
|
||||
});
|
||||
const lastFrame = source.last_frame === undefined || source.last_frame === null ? null : mediaInput(source.last_frame, "last_frame");
|
||||
if (source.keyframes !== undefined && source.keyframes !== null && !Array.isArray(source.keyframes)) throw new Error("keyframes must be an array");
|
||||
const keyframes = (source.keyframes || []).map(function (keyframe, index) {
|
||||
const object = plainObject(keyframe);
|
||||
const timestamp = object ? Number(object.timestamp_s) : NaN;
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) throw new Error("keyframes[" + index + "].timestamp_s must be a positive number");
|
||||
return { image: mediaInput(object.image, "keyframes[" + index + "].image"), timestamp_s: timestamp };
|
||||
});
|
||||
if (keyframes.length > MAX_KEYFRAMES) throw new Error("keyframes accepts at most " + MAX_KEYFRAMES + " items");
|
||||
if (source.reference_audios !== undefined && source.reference_audios !== null && !Array.isArray(source.reference_audios))
|
||||
throw new Error("reference_audios must be an array");
|
||||
const referenceAudios = (source.reference_audios || []).map(function (audio, index) {
|
||||
const object = plainObject(audio) || {};
|
||||
if (typeof object.voice_id === "string" && object.voice_id.trim()) return { voice_id: object.voice_id.trim() };
|
||||
if (typeof object.url === "string" && object.url.trim()) return { url: object.url.trim() };
|
||||
throw new Error("reference_audios[" + index + "] must be {voice_id} or {url}");
|
||||
});
|
||||
if (referenceAudios.length > MAX_REFERENCE_AUDIOS) throw new Error("reference_audios accepts at most " + MAX_REFERENCE_AUDIOS + " items");
|
||||
|
||||
let action = "text_to_video";
|
||||
if (referenceImages.length || referenceAudios.length) action = "reference_to_video";
|
||||
else if (image || lastFrame || keyframes.length) action = "image_to_video";
|
||||
const prompt = promptField(source, action !== "image_to_video");
|
||||
|
||||
const resolution = source.resolution === undefined || source.resolution === null ? DEFAULT_RESOLUTION : trimmed(source.resolution).toLowerCase();
|
||||
const resolutions = VIDEO_MODELS[model].resolutions;
|
||||
if (!resolutions.includes(resolution)) throw new Error(model + " resolution must be one of " + resolutions.join(", "));
|
||||
if (action === "reference_to_video" && resolution === "1080p") throw new Error("reference-to-video supports resolution 480p or 720p");
|
||||
const duration = source.duration === undefined || source.duration === null ? DEFAULT_DURATION : integerField(source.duration, "duration", 1, MAX_DURATION);
|
||||
|
||||
const body = { duration: duration, resolution: resolution };
|
||||
if (prompt) body.prompt = prompt;
|
||||
if (source.aspect_ratio !== undefined && source.aspect_ratio !== null) {
|
||||
if (!ASPECT_RATIOS.includes(source.aspect_ratio)) throw new Error("aspect_ratio must be one of " + ASPECT_RATIOS.join(", "));
|
||||
body.aspect_ratio = source.aspect_ratio;
|
||||
}
|
||||
if (image) body.image = image;
|
||||
if (referenceImages.length) body.reference_images = referenceImages;
|
||||
if (lastFrame) body.last_frame = lastFrame;
|
||||
if (keyframes.length) body.keyframes = keyframes;
|
||||
if (referenceAudios.length) body.reference_audios = referenceAudios;
|
||||
if (source.generate_audio !== undefined && source.generate_audio !== null) {
|
||||
if (typeof source.generate_audio !== "boolean") throw new Error("generate_audio must be a boolean");
|
||||
body.generate_audio = source.generate_audio;
|
||||
}
|
||||
return { action: action, body: commonFields(source, body) };
|
||||
}
|
||||
|
||||
// Edits and extensions take an input video; the output keeps its resolution
|
||||
// and aspect ratio, so those fields are not forwarded.
|
||||
function videoInputRequest(action, source, identity) {
|
||||
const model = videoModel(identity);
|
||||
if (!VIDEO_MODELS[model].videoInput) throw new Error("video editing and extension require grok-imagine-video");
|
||||
const body = { prompt: promptField(source, true), video: mediaInput(source.video, "video") };
|
||||
if (action === "video_extension") {
|
||||
body.duration =
|
||||
source.duration === undefined || source.duration === null
|
||||
? DEFAULT_EXTENSION_SECONDS
|
||||
: integerField(source.duration, "duration", MIN_EXTENSION_SECONDS, MAX_EXTENSION_SECONDS);
|
||||
}
|
||||
return { action: action, body: commonFields(source, body) };
|
||||
}
|
||||
|
||||
function videoRequest(action, source, identity) {
|
||||
if (action === "video_edit" || action === "video_extension") return videoInputRequest(action, source, identity);
|
||||
return generationRequest(generationSource(source), identity);
|
||||
}
|
||||
|
||||
// OpenAI-style WIDTHxHEIGHT sizes select the nearest xAI aspect ratio and the
|
||||
// resolution of the shorter side.
|
||||
function sizeOptions(size) {
|
||||
const text = trimmed(size).toLowerCase();
|
||||
if (["480p", "720p", "1080p"].includes(text)) return { resolution: text };
|
||||
const parts = text.replace("*", "x").split("x");
|
||||
const width = Number(parts[0]);
|
||||
const height = Number(parts[1]);
|
||||
if (parts.length !== 2 || !(width > 0) || !(height > 0)) throw new Error("size must be WIDTHxHEIGHT or one of 480p, 720p, 1080p");
|
||||
let aspectRatio = ASPECT_RATIOS[0];
|
||||
let nearest = Infinity;
|
||||
for (const ratio of ASPECT_RATIOS) {
|
||||
const sides = ratio.split(":").map(Number);
|
||||
const distance = Math.abs(Math.log(width / height) - Math.log(sides[0] / sides[1]));
|
||||
if (distance < nearest) {
|
||||
nearest = distance;
|
||||
aspectRatio = ratio;
|
||||
}
|
||||
}
|
||||
const shorter = Math.min(width, height);
|
||||
return { aspect_ratio: aspectRatio, resolution: shorter >= 1080 ? "1080p" : shorter >= 720 ? "720p" : "480p" };
|
||||
}
|
||||
|
||||
// Normalizes every request shape that reaches the driver: the canonical xAI
|
||||
// body, OpenAI video fields (seconds, size, input_reference), and the legacy
|
||||
// task shape (images, metadata) that host routes without a plugin decoder
|
||||
// pass through. Explicit xAI fields win over metadata and size.
|
||||
function generationSource(req) {
|
||||
const source = Object.assign(
|
||||
{},
|
||||
plainObject(req.metadata) || {},
|
||||
req.size === undefined || req.size === null || req.size === "" ? {} : sizeOptions(req.size),
|
||||
);
|
||||
for (const key of Object.keys(req)) {
|
||||
if (req[key] !== undefined && req[key] !== null && req[key] !== "") source[key] = req[key];
|
||||
}
|
||||
if (source.duration === undefined && source.seconds !== undefined) source.duration = source.seconds;
|
||||
if (source.image === undefined && source.input_reference !== undefined) source.image = source.input_reference;
|
||||
const images = Array.isArray(req.images) ? req.images : [];
|
||||
if (source.image === undefined && images.length === 1) source.image = images[0];
|
||||
if (source.reference_images === undefined && images.length > 1) source.reference_images = images;
|
||||
return source;
|
||||
}
|
||||
|
||||
// A moderated output still reports done, with respect_moderation false and no URL.
|
||||
function filteredOutput(data) {
|
||||
const video = plainObject(data) && data.status === "done" ? plainObject(data.video) : null;
|
||||
return Boolean(video && video.respect_moderation === false);
|
||||
}
|
||||
|
||||
// xAI reports the amount it billed on every video result. A failed result it
|
||||
// still billed is charged rather than refunded.
|
||||
function chargedFailure(data) {
|
||||
const usage = plainObject(data) && data.status === "failed" ? plainObject(data.usage) : null;
|
||||
return Boolean(usage && Number(usage.cost_in_usd_ticks) > 0);
|
||||
}
|
||||
|
||||
function moderationNotice(data) {
|
||||
if (filteredOutput(data)) return "The generated video was blocked by content moderation.";
|
||||
if (!chargedFailure(data)) return "";
|
||||
const error = plainObject(data.error);
|
||||
return error && trimmed(error.message) ? trimmed(error.message) : "The request was blocked by content moderation.";
|
||||
}
|
||||
|
||||
function videoURL(data) {
|
||||
const video = plainObject(data) && data.status === "done" ? plainObject(data.video) : null;
|
||||
return video && video.respect_moderation !== false ? trimmed(video.url) : "";
|
||||
}
|
||||
|
||||
function failureReason(data) {
|
||||
const error = plainObject(data && data.error);
|
||||
return error && trimmed(error.message) ? trimmed(error.message) : "video generation failed";
|
||||
}
|
||||
|
||||
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, task) {
|
||||
const notice = moderationNotice(task && task.data);
|
||||
if (notice) return notice;
|
||||
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>';
|
||||
}
|
||||
|
||||
function nativeRequest(ctx, action) {
|
||||
if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
|
||||
const body = plainObject(ctx.body.value);
|
||||
if (!body) throw new Error("request body must be an object");
|
||||
const model = typeof body.model === "string" ? body.model.trim() : "";
|
||||
if (!model) throw new Error("model is required");
|
||||
const request = videoRequest(action, body, { model: model });
|
||||
return { kind: "submit", model: model, action: request.action, requestBody: Object.assign(request.body, { model: model }) };
|
||||
}
|
||||
|
||||
export const native = {
|
||||
createGeneration: function (ctx) {
|
||||
return nativeRequest(ctx, "generation");
|
||||
},
|
||||
createEdit: function (ctx) {
|
||||
return nativeRequest(ctx, "video_edit");
|
||||
},
|
||||
createExtension: function (ctx) {
|
||||
return nativeRequest(ctx, "video_extension");
|
||||
},
|
||||
videoCreated: function (ctx, task) {
|
||||
return { request_id: task.task_id };
|
||||
},
|
||||
videoStatus: function (ctx, task) {
|
||||
const data = plainObject(task.data);
|
||||
// The poller can fail a task whose last snapshot was still pending.
|
||||
if (data && typeof data.status === "string" && !(task.status === "FAILURE" && data.status === "pending")) {
|
||||
const output = Object.assign({}, data);
|
||||
delete output.request_id;
|
||||
// The gateway's upstream cost is not the caller's price.
|
||||
delete output.usage;
|
||||
// A billed failure settled as charged. Report it in xAI's moderated
|
||||
// output shape so callers, including a downstream gateway running this
|
||||
// plugin, see a charged result without the upstream cost.
|
||||
if (task.status === "SUCCESS" && chargedFailure(data)) {
|
||||
output.status = "done";
|
||||
output.progress = 100;
|
||||
output.video = { respect_moderation: false };
|
||||
}
|
||||
return output;
|
||||
}
|
||||
if (task.status === "FAILURE") return { status: "failed", error: { code: "internal_error", message: task.fail_reason || "video generation failed" } };
|
||||
const progress = Number(String(task.progress || "0").replace("%", ""));
|
||||
return { status: "pending", progress: Number.isFinite(progress) ? Math.min(Math.max(progress, 0), 99) : 0 };
|
||||
},
|
||||
error: function (ctx, error) {
|
||||
return { error: { code: error.code, message: error.message } };
|
||||
},
|
||||
};
|
||||
|
||||
export function buildSubmitRequest(ctx) {
|
||||
const request = videoRequest(ctx.action, ctx.requestBody || {}, ctx);
|
||||
const paths = { video_edit: "/v1/videos/edits", video_extension: "/v1/videos/extensions" };
|
||||
return {
|
||||
url: apiRoot(ctx) + (paths[request.action] || "/v1/videos/generations"),
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: "Bearer " + ctx.apiKey },
|
||||
body: Object.assign({}, request.body, { model: ctx.upstreamModel || ctx.model }),
|
||||
action: request.action,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSubmitResponse(ctx, resp) {
|
||||
const body = plainObject(resp.body);
|
||||
const taskId = body && typeof body.request_id === "string" ? body.request_id.trim() : "";
|
||||
if (!taskId) throw new Error("xAI did not return a request_id");
|
||||
const result = { taskId: taskId, taskData: body };
|
||||
// The result reports only the total length, so completion needs the
|
||||
// extension length to derive the input video seconds.
|
||||
if (ctx.action === "video_extension") result.state = { extension_seconds: (ctx.requestBody || {}).duration || DEFAULT_EXTENSION_SECONDS };
|
||||
return result;
|
||||
}
|
||||
|
||||
export function extractUsage(ctx) {
|
||||
const request = videoRequest(ctx.action, ctx.requestBody || {}, ctx);
|
||||
const body = request.body;
|
||||
const videoInput = VIDEO_MODELS[videoModel(ctx)].videoInput;
|
||||
let facts;
|
||||
if (request.action === "video_edit") {
|
||||
// Input URLs expose neither duration nor resolution; reserve the
|
||||
// documented maximum and the resolution cap, and settle on the result.
|
||||
facts = { seconds: MAX_EDIT_SECONDS, resolution: EDIT_RESOLUTION_CAP, input_images: 0, input_video_seconds: MAX_EDIT_SECONDS };
|
||||
} else if (request.action === "video_extension") {
|
||||
facts = { seconds: body.duration, resolution: EDIT_RESOLUTION_CAP, input_images: 0, input_video_seconds: MAX_EXTENSION_INPUT_SECONDS };
|
||||
} else {
|
||||
const images = (body.image ? 1 : 0) + (body.reference_images || []).length + (body.last_frame ? 1 : 0) + (body.keyframes || []).length;
|
||||
facts = { seconds: body.duration, resolution: body.resolution, input_images: images, input_video_seconds: 0 };
|
||||
}
|
||||
// Legacy per-call prices scale with the output length only.
|
||||
if (ctx.usagePurpose === "billing_ratios") return { seconds: facts.seconds };
|
||||
if (!videoInput) delete facts.input_video_seconds;
|
||||
facts.moderated = false;
|
||||
return facts;
|
||||
}
|
||||
|
||||
export function buildQueryRequest(ctx) {
|
||||
return {
|
||||
url: apiRoot(ctx) + "/v1/videos/" + encodeURIComponent(ctx.taskId),
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json", Authorization: "Bearer " + ctx.apiKey },
|
||||
};
|
||||
}
|
||||
|
||||
export function parseTaskResult(ctx, body, response) {
|
||||
const data = plainObject(body);
|
||||
// xAI answers 202 while the video renders.
|
||||
if (!data && response && response.status === 202) return { status: "IN_PROGRESS" };
|
||||
const status = data ? data.status : undefined;
|
||||
if (status === "pending") {
|
||||
const result = { status: "IN_PROGRESS" };
|
||||
const progress = Number(data.progress);
|
||||
if (progress > 0 && progress < 100) result.progress = progress + "%";
|
||||
return result;
|
||||
}
|
||||
// A moderated output is done without a video. xAI still bills it, and only
|
||||
// a SUCCESS task keeps its charge.
|
||||
if (status === "done") {
|
||||
const result = { status: "SUCCESS" };
|
||||
// An edit or extension is billed by the resolution of its output, which
|
||||
// only the file reveals; an unreadable file keeps the reserved cap.
|
||||
if ((ctx.action === "video_edit" || ctx.action === "video_extension") && videoURL(data)) {
|
||||
const resolution = outputResolution(videoURL(data));
|
||||
if (resolution) result.state = Object.assign({}, plainObject(ctx.state) || {}, { output_resolution: resolution });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (status === "failed") return chargedFailure(data) ? { status: "SUCCESS" } : { status: "FAILURE", reason: failureReason(data) };
|
||||
if (status === "expired") return { status: "FAILURE", reason: "the video expired before it was retrieved" };
|
||||
return { status: "UNKNOWN", reason: "unrecognized status: " + String(status || "") };
|
||||
}
|
||||
|
||||
export function extractUsageOnComplete(task, result, body) {
|
||||
// Legacy ratio pricing calls this hook without a poll body.
|
||||
const data = plainObject(body);
|
||||
if (!data || typeof data.status !== "string") return null;
|
||||
const facts = {};
|
||||
if (filteredOutput(data) || chargedFailure(data)) facts.moderated = true;
|
||||
// The state this poll returned arrives with its result; earlier polls left
|
||||
// theirs on the task.
|
||||
const state = plainObject(result && result.plugin_state) || plainObject(task && task.state) || {};
|
||||
if (VIDEO_MODELS["grok-imagine-video"].resolutions.includes(state.output_resolution)) facts.resolution = state.output_resolution;
|
||||
const duration = Number(data.status === "done" && plainObject(data.video) ? data.video.duration : NaN);
|
||||
if (!(duration > 0)) return facts;
|
||||
const action = task && task.action;
|
||||
if (action === "video_edit") {
|
||||
facts.seconds = Math.min(duration, MAX_EDIT_SECONDS);
|
||||
facts.input_video_seconds = facts.seconds;
|
||||
} else if (action === "video_extension") {
|
||||
// The extension length was reserved as seconds; the rest of the output is
|
||||
// the input video.
|
||||
const extension = Number(state.extension_seconds);
|
||||
if (extension > 0) facts.input_video_seconds = Math.min(Math.max(duration - extension, 0), MAX_EXTENSION_INPUT_SECONDS);
|
||||
} else {
|
||||
facts.seconds = Math.min(duration, MAX_DURATION);
|
||||
}
|
||||
return facts;
|
||||
}
|
||||
|
||||
// xAI bills the output of an edit or extension by its resolution but reports
|
||||
// neither it nor the source's, so the output's MP4 header is read: the moov
|
||||
// box, reached by stepping over the top-level boxes with ranged reads, holds a
|
||||
// tkhd per track with the track's size.
|
||||
const PROBE_READ_BYTES = 64 * 1024;
|
||||
const PROBE_MAX_BYTES = 1024 * 1024; // the utils.fetch body limit
|
||||
const PROBE_MAX_READS = 3; // utils.fetch allows 4 per hook call
|
||||
|
||||
function outputResolution(url) {
|
||||
try {
|
||||
const track = videoTrack(url);
|
||||
if (!track) return "";
|
||||
return Math.min(track.width, track.height) > 480 ? "720p" : "480p";
|
||||
} catch (_error) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// Reads bytes [start, start + length) of the file. A server that ignores
|
||||
// Range answers 200 with the whole file, bounded by the fetch body limit.
|
||||
function readBytes(url, start, length) {
|
||||
const response = utils.fetch({ url: url, headers: { Range: "bytes=" + start + "-" + (start + length - 1) }, responseType: "bytes" });
|
||||
if (response.status !== 206 && response.status !== 200) throw new Error("HTTP " + response.status);
|
||||
let first = 0;
|
||||
if (response.status === 206) {
|
||||
const range = /^bytes (\d+)-/.exec(response.headers["Content-Range"] || "");
|
||||
first = range ? Number(range[1]) : start;
|
||||
}
|
||||
return { start: first, bytes: new Uint8Array(response.body) };
|
||||
}
|
||||
|
||||
function be32(bytes, offset) {
|
||||
return bytes[offset] * 16777216 + bytes[offset + 1] * 65536 + bytes[offset + 2] * 256 + bytes[offset + 3];
|
||||
}
|
||||
|
||||
// An ISO BMFF box header: a 32-bit size and a four-character type, with a
|
||||
// 64-bit size following when the size is 1; a size of 0 reaches end.
|
||||
function boxHeader(bytes, offset, end) {
|
||||
if (offset + 8 > end) return null;
|
||||
let size = be32(bytes, offset);
|
||||
let header = 8;
|
||||
if (size === 1) {
|
||||
if (offset + 16 > end) return null;
|
||||
size = be32(bytes, offset + 8) * 4294967296 + be32(bytes, offset + 12);
|
||||
header = 16;
|
||||
}
|
||||
const open = size === 0;
|
||||
if (open) size = end - offset;
|
||||
if (size < header) return null;
|
||||
return { type: String.fromCharCode(bytes[offset + 4], bytes[offset + 5], bytes[offset + 6], bytes[offset + 7]), size: size, header: header, open: open };
|
||||
}
|
||||
|
||||
function videoTrack(url) {
|
||||
let reads = 0;
|
||||
let chunk = { start: 0, bytes: new Uint8Array(0) };
|
||||
const read = function (start, length) {
|
||||
if (reads >= PROBE_MAX_READS) throw new Error("too many reads");
|
||||
reads++;
|
||||
chunk = readBytes(url, start, length);
|
||||
};
|
||||
let offset = 0;
|
||||
for (let i = 0; i < 32; i++) {
|
||||
if (offset < chunk.start || offset + 16 > chunk.start + chunk.bytes.length) read(offset, PROBE_READ_BYTES);
|
||||
let local = offset - chunk.start;
|
||||
let box = local >= 0 ? boxHeader(chunk.bytes, local, chunk.bytes.length) : null;
|
||||
if (!box) return null;
|
||||
if (box.type !== "moov") {
|
||||
offset += box.size;
|
||||
continue;
|
||||
}
|
||||
if (box.open || local + box.size > chunk.bytes.length) {
|
||||
read(offset, box.open ? PROBE_MAX_BYTES : Math.min(box.size, PROBE_MAX_BYTES));
|
||||
local = offset - chunk.start;
|
||||
box = local >= 0 ? boxHeader(chunk.bytes, local, chunk.bytes.length) : null;
|
||||
if (!box) return null;
|
||||
}
|
||||
return moovVideoTrack(chunk.bytes, local + box.header, Math.min(local + box.size, chunk.bytes.length));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// The largest track with a size is the video; audio tracks have none.
|
||||
function moovVideoTrack(bytes, start, end) {
|
||||
let best = null;
|
||||
let offset = start;
|
||||
while (true) {
|
||||
const box = boxHeader(bytes, offset, end);
|
||||
if (!box || offset + box.size > end) return best;
|
||||
if (box.type === "trak") {
|
||||
const track = trackSize(bytes, offset + box.header, offset + box.size);
|
||||
if (track && (!best || track.width * track.height > best.width * best.height)) best = track;
|
||||
}
|
||||
offset += box.size;
|
||||
}
|
||||
}
|
||||
|
||||
// tkhd stores the track's width and height as 16.16 fixed-point numbers after
|
||||
// fields whose size depends on its version.
|
||||
function trackSize(bytes, start, end) {
|
||||
let offset = start;
|
||||
while (true) {
|
||||
const box = boxHeader(bytes, offset, end);
|
||||
if (!box || offset + box.size > end) return null;
|
||||
if (box.type === "tkhd") {
|
||||
const fields = offset + box.header;
|
||||
const width = fields + 4 + (bytes[fields] === 1 ? 84 : 72);
|
||||
if (width + 8 > offset + box.size) return null;
|
||||
const track = { width: bytes[width] * 256 + bytes[width + 1], height: bytes[width + 4] * 256 + bytes[width + 5] };
|
||||
return track.width > 0 && track.height > 0 ? track : null;
|
||||
}
|
||||
offset += box.size;
|
||||
}
|
||||
}
|
||||
|
||||
export function listArtifacts(task) {
|
||||
return task.status === "SUCCESS" && videoURL(task.data) ? [{ key: "video", type: "video", mimeType: "video/mp4" }] : [];
|
||||
}
|
||||
|
||||
export function buildContentRequest(ctx) {
|
||||
if (ctx.artifactKey !== "video") throw new Error("artifact_not_found");
|
||||
const url = videoURL(ctx.data);
|
||||
if (!url) throw new Error("artifact_not_found");
|
||||
return { url: url, method: ctx.clientRequest.method, credentialless: true };
|
||||
}
|
||||
|
||||
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 images = [];
|
||||
for (const image of [req.image, req.input_reference].concat(req.images || [], input.images)) {
|
||||
if (typeof image === "string" && image.trim() && !images.includes(image.trim())) images.push(image.trim());
|
||||
}
|
||||
// metadata carries xAI-only fields such as keyframes or generate_audio.
|
||||
const source = { prompt: input.prompt || trimmed(req.prompt), images: images };
|
||||
for (const key of ["metadata", "seconds", "duration", "size", "aspect_ratio", "resolution"]) {
|
||||
if (req[key] !== undefined) source[key] = req[key];
|
||||
}
|
||||
if (!source.prompt && !images.length) throw new Error("input is required");
|
||||
const request = videoRequest("generation", source, { upstreamModel: ctx.upstreamModel, model: model });
|
||||
return { kind: "submit", model: model, action: request.action, requestBody: Object.assign(request.body, { model: model }) };
|
||||
},
|
||||
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, task);
|
||||
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, task), annotations: [], logprobs: [] }],
|
||||
},
|
||||
],
|
||||
metadata: { vendor: "xai" },
|
||||
};
|
||||
},
|
||||
},
|
||||
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;
|
||||
if (ctx.body.kind === "json") {
|
||||
req = plainObject(ctx.body.value);
|
||||
if (!req) throw new Error("JSON object required");
|
||||
} else {
|
||||
req = {};
|
||||
const fields = ctx.body.fields || {};
|
||||
for (const name of Object.keys(fields)) {
|
||||
if (fields[name].length > 1) throw new Error(name + " must be provided once");
|
||||
req[name] = fields[name][0];
|
||||
}
|
||||
if (req.generate_audio !== undefined) {
|
||||
if (req.generate_audio !== "true" && req.generate_audio !== "false") throw new Error("generate_audio must be true or false");
|
||||
req.generate_audio = req.generate_audio === "true";
|
||||
}
|
||||
const files = ctx.body.files || [];
|
||||
for (const file of files) {
|
||||
if (file.field !== "input_reference") throw new Error("unexpected file field: " + file.field);
|
||||
}
|
||||
if (files.length > 1) throw new Error("input_reference must be provided once");
|
||||
if (files.length) req.input_reference = { url: { __fileRef: files[0].ref, encoding: "dataUrl" } };
|
||||
}
|
||||
const request = videoRequest("generation", req, { upstreamModel: ctx.upstreamModel, model: ctx.model });
|
||||
return { kind: "submit", model: ctx.model, action: request.action, requestBody: Object.assign(request.body, { model: ctx.model }) };
|
||||
},
|
||||
// The host owns id, status, progress, and timestamps; a moderated video
|
||||
// reports completed (it is charged) and explains the missing content here.
|
||||
render: function (ctx, task) {
|
||||
const data = plainObject(task.data) || {};
|
||||
const output = { object: "video" };
|
||||
const seconds = Number(plainObject(data.video) ? data.video.duration : NaN);
|
||||
if (seconds > 0) output.seconds = String(seconds);
|
||||
const notice = task.status === "SUCCESS" ? moderationNotice(data) : "";
|
||||
if (notice) output.error = { code: "moderation_blocked", message: notice };
|
||||
if (task.status === "FAILURE")
|
||||
output.error = {
|
||||
code: trimmed(plainObject(data.error) && data.error.code) || "video_generation_failed",
|
||||
message: task.fail_reason || failureReason(data),
|
||||
};
|
||||
return output;
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user