mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-08 01:17:09 +08:00
The "source" resolution tier was not an xAI price. xAI bills edits and extensions at the output resolution, which keeps the input video's resolution capped at 720p and is reported nowhere. xai 1.1.0 reserves 720p, then reads the output MP4's track header with ranged utils.fetch calls to vidgen.x.ai and settles at 480p when the shorter side is at most 480 pixels. If the header cannot be read, the task settles at 720p.
784 lines
37 KiB
JavaScript
784 lines
37 KiB
JavaScript
// 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;
|
|
},
|
|
},
|
|
};
|