mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-07 18:18:00 +00:00
480 lines
20 KiB
JavaScript
480 lines
20 KiB
JavaScript
export const meta = {
|
||
apiVersion: 1,
|
||
key: "kling",
|
||
name: "Kling",
|
||
icon: "Kling.Color",
|
||
description: {
|
||
en: "Kuaishou Kling video generation (text-to-video and image-to-video)",
|
||
zh: "快手可灵视频生成(文生视频、图生视频)",
|
||
},
|
||
version: "1.0.0",
|
||
author: { name: "QuantumNous" },
|
||
channelTypes: [50],
|
||
models: ["kling-v1", "kling-v1-6", "kling-v2-master"],
|
||
fetchMode: "per_task",
|
||
usageSchema: {
|
||
units: {
|
||
type: "number",
|
||
unit: "credit",
|
||
description: {
|
||
en: "Kling final unit deduction (estimated at submit, actual on completion).",
|
||
zh: "可灵最终单位消耗(提交时预估,完成后按实际值)。",
|
||
},
|
||
},
|
||
},
|
||
usageExamples: [
|
||
{ label: "v1 std 5s", facts: { units: 1 } },
|
||
{ label: "v1 pro 5s", facts: { units: 3.5 } },
|
||
{ label: "v1-6 std 5s", facts: { units: 2 } },
|
||
{ label: "v1-6 pro 10s", facts: { units: 7 } },
|
||
{ label: "v2-master pro 5s", facts: { units: 10 } },
|
||
],
|
||
protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
|
||
routes: [
|
||
{ method: "POST", path: "/kling/v1/videos/text2video", type: "submit", action: "text_to_video", decode: "decodeSubmit", render: "taskCreated" },
|
||
{ method: "POST", path: "/kling/v1/videos/image2video", type: "submit", action: "image_to_video", decode: "decodeSubmit", render: "taskCreated" },
|
||
{ method: "GET", path: "/kling/v1/videos/text2video/:task_id", type: "query", render: "taskStatus" },
|
||
{ method: "GET", path: "/kling/v1/videos/image2video/:task_id", type: "query", render: "taskStatus" },
|
||
],
|
||
};
|
||
|
||
// Official unit consumption (units per output video second), not a currency price.
|
||
// Source: https://kling.ai/dev/pricing
|
||
const UNITS_PER_SECOND = {
|
||
"kling-v1": { std: 0.2, pro: 0.7 },
|
||
"kling-v1-6": { std: 0.4, pro: 0.7 },
|
||
"kling-v2-master": { pro: 2.0 },
|
||
};
|
||
|
||
function trimmed(value) {
|
||
return String(value || "").trim();
|
||
}
|
||
|
||
function responsesInput(req) {
|
||
const texts = [],
|
||
images = [];
|
||
const input = req.input;
|
||
if (typeof input === "string") texts.push(input);
|
||
else if (Array.isArray(input)) {
|
||
for (const item of input) {
|
||
if (typeof item === "string") {
|
||
texts.push(item);
|
||
continue;
|
||
}
|
||
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
||
const content = item.content === undefined ? [item] : Array.isArray(item.content) ? item.content : [item.content];
|
||
for (const part of content) {
|
||
if (typeof part === "string") {
|
||
texts.push(part);
|
||
continue;
|
||
}
|
||
if (!part || typeof part !== "object" || Array.isArray(part)) continue;
|
||
if (["input_text", "text"].includes(part.type) && typeof part.text === "string") texts.push(part.text);
|
||
if (["input_image", "image_url"].includes(part.type)) {
|
||
let image = part.image_url;
|
||
if (image && typeof image === "object") image = image.url;
|
||
if (trimmed(image)) images.push(trimmed(image));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return {
|
||
prompt: texts
|
||
.filter(function (text) {
|
||
return trimmed(text);
|
||
})
|
||
.join("\n"),
|
||
images: images,
|
||
};
|
||
}
|
||
|
||
function responsesVideoText(ctx) {
|
||
const artifact = ctx && ctx.artifacts && ctx.artifacts.video;
|
||
const url = trimmed(artifact && artifact.url);
|
||
if (!url) throw new Error("video artifact is unavailable");
|
||
const escaped = url.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||
return '<video controls src="' + escaped + '"></video>';
|
||
}
|
||
|
||
function isRelay(apiKey) {
|
||
return apiKey.startsWith("sk-");
|
||
}
|
||
|
||
function tokenFor(apiKey) {
|
||
if (isRelay(apiKey)) return apiKey;
|
||
const parts = 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());
|
||
}
|
||
|
||
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 aspectRatio(size) {
|
||
const ratios = { "1024x1024": "1:1", "512x512": "1:1", "1280x720": "16:9", "1920x1080": "16:9", "720x1280": "9:16", "1080x1920": "9:16" };
|
||
return ratios[size] || "1:1";
|
||
}
|
||
|
||
function submitModel(ctx, req) {
|
||
return (ctx && ctx.upstreamModel) || (ctx && ctx.model) || (req && req.model) || "kling-v1";
|
||
}
|
||
|
||
function resolveKlingMode(model, mode) {
|
||
const raw = trimmed(mode).toLowerCase();
|
||
if (model === "kling-v2-master") {
|
||
if (raw === "std") throw new Error("kling-v2-master does not support mode std");
|
||
if (raw && raw !== "pro") throw new Error("mode must be pro");
|
||
return "pro";
|
||
}
|
||
if (!raw) return "std";
|
||
if (raw !== "std" && raw !== "pro") throw new Error("mode must be std or pro");
|
||
return raw;
|
||
}
|
||
|
||
function perSecondRate(model, mode) {
|
||
const table = UNITS_PER_SECOND[model] || UNITS_PER_SECOND["kling-v1"];
|
||
if (table[mode] !== undefined) return table[mode];
|
||
if (table.pro !== undefined) return table.pro;
|
||
return table.std;
|
||
}
|
||
|
||
function estimateUnits(model, mode, durationSeconds) {
|
||
return perSecondRate(model, mode) * durationSeconds;
|
||
}
|
||
|
||
// Official current pages list 3–15s for new models; old-model duration "5"|"10"
|
||
// is unverifiable (research 2026-08-27). Keep permissive positive integers up to
|
||
// the host task duration bound.
|
||
function validateKlingDuration(value) {
|
||
const n = Number(value);
|
||
if (!Number.isInteger(n) || n <= 0 || n > 3600) throw new Error("seconds must be a positive integer at most 3600");
|
||
return n;
|
||
}
|
||
|
||
function outboundDuration(req) {
|
||
const n = Number(req && req.duration);
|
||
if (Number.isFinite(n) && n > 0) return n;
|
||
const metadata = (req && req.metadata) || {};
|
||
const fromMeta = Number(metadata.duration);
|
||
if (Number.isFinite(fromMeta) && fromMeta > 0) return fromMeta;
|
||
return 5;
|
||
}
|
||
|
||
function outboundMode(req, model) {
|
||
const metadata = (req && req.metadata) || {};
|
||
return resolveKlingMode(model, (req && req.mode) || metadata.mode);
|
||
}
|
||
|
||
function hasKlingImage(req, hasInputReferenceFile) {
|
||
if (hasInputReferenceFile) return true;
|
||
const metadata = (req && req.metadata) || {};
|
||
if (req && req.image && typeof req.image === "object" && !Array.isArray(req.image) && req.image.__fileRef) return true;
|
||
return Boolean(trimmed(req && req.input_reference) || trimmed(req && req.image) || metadata.image || metadata.image_tail);
|
||
}
|
||
|
||
function filePlaceholder(image) {
|
||
if (!image || typeof image !== "object" || Array.isArray(image) || !image.__fileRef) return image;
|
||
const placeholder = { __fileRef: image.__fileRef, encoding: image.encoding };
|
||
if (image.mimeType) placeholder.mimeType = image.mimeType;
|
||
if (image.maxBytes !== undefined && image.maxBytes !== null) placeholder.maxBytes = image.maxBytes;
|
||
return placeholder;
|
||
}
|
||
|
||
function decodeNativeSubmit(ctx) {
|
||
if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
|
||
const body = ctx.body.value;
|
||
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("request body must be an object");
|
||
let model = typeof body.model_name === "string" ? body.model_name : "";
|
||
if (model === "") model = typeof body.model === "string" ? body.model : "";
|
||
if (!model.trim()) throw new Error("model is required");
|
||
return {
|
||
kind: "submit",
|
||
model: model,
|
||
requestBody: {
|
||
model: model,
|
||
prompt: typeof body.prompt === "string" ? body.prompt : "",
|
||
metadata: body,
|
||
},
|
||
};
|
||
}
|
||
|
||
export const native = {
|
||
decodeSubmit: decodeNativeSubmit,
|
||
taskCreated: function (ctx, task) {
|
||
const result = task.data || {},
|
||
data = result.data || {};
|
||
return Object.assign({}, result, { data: Object.assign({}, data, { task_id: task.task_id }) });
|
||
},
|
||
taskStatus: function (ctx, task) {
|
||
if (task.data && typeof task.data === "object" && !Array.isArray(task.data)) {
|
||
const result = task.data,
|
||
data = result.data && typeof result.data === "object" ? result.data : {};
|
||
return Object.assign({}, result, { data: Object.assign({}, data, { task_id: task.task_id }) });
|
||
}
|
||
const statusMap = { NOT_START: "submitted", SUBMITTED: "submitted", QUEUED: "submitted", IN_PROGRESS: "processing", SUCCESS: "succeed", FAILURE: "failed" };
|
||
return { code: 0, data: { task_id: task.task_id, task_status: statusMap[task.status] || "submitted", task_status_msg: task.fail_reason || "" } };
|
||
},
|
||
error: function (ctx, error) {
|
||
return { code: error.code, message: error.message };
|
||
},
|
||
};
|
||
|
||
export function buildSubmitRequest(ctx) {
|
||
const req = ctx.requestBody;
|
||
const metadata = req.metadata || {};
|
||
const inferredAction = req.image || metadata.image || metadata.image_tail ? "image_to_video" : "text_to_video";
|
||
const action = ctx.action === "text_to_video" || ctx.action === "image_to_video" ? ctx.action : inferredAction;
|
||
const model = ctx.upstreamModel || "kling-v1";
|
||
const body = Object.assign(
|
||
{
|
||
prompt: req.prompt,
|
||
image: req.image,
|
||
mode: outboundMode(req, model),
|
||
duration: String(req.duration || 5),
|
||
aspect_ratio: aspectRatio(req.size),
|
||
model_name: model,
|
||
model: model,
|
||
cfg_scale: 0.5,
|
||
},
|
||
metadata
|
||
);
|
||
body.mode = outboundMode({ mode: body.mode, metadata: metadata }, model);
|
||
if (body.image) body.image = filePlaceholder(body.image);
|
||
if (body.image_tail) body.image_tail = filePlaceholder(body.image_tail);
|
||
if (!body.prompt) delete body.prompt;
|
||
if (!body.image) delete body.image;
|
||
return {
|
||
url: urlFor(ctx.baseUrl, ctx.apiKey, action),
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: "Bearer " + tokenFor(ctx.apiKey), "User-Agent": "kling-sdk/1.0" },
|
||
body: body,
|
||
action: action,
|
||
};
|
||
}
|
||
|
||
export function parseSubmitResponse(ctx, resp) {
|
||
const result = resp.body || {};
|
||
if (result.code !== 0) throw new Error(result.message || "kling submit failed");
|
||
if (!result.data || !result.data.task_id) throw new Error("missing task_id");
|
||
return { taskId: result.data.task_id, taskData: result };
|
||
}
|
||
|
||
export function extractUsage(ctx) {
|
||
if (ctx.usagePurpose === "billing_ratios") return null;
|
||
const req = ctx.requestBody || {};
|
||
const model = submitModel(ctx, req);
|
||
const duration = outboundDuration(req);
|
||
const mode = outboundMode(req, model);
|
||
return { units: estimateUnits(model, mode, duration) };
|
||
}
|
||
|
||
export function buildQueryRequest(ctx) {
|
||
return {
|
||
url: urlFor(ctx.baseUrl, ctx.apiKey, ctx.action) + "/" + ctx.taskId,
|
||
method: "GET",
|
||
headers: { Accept: "application/json", Authorization: "Bearer " + tokenFor(ctx.apiKey), "User-Agent": "kling-sdk/1.0" },
|
||
};
|
||
}
|
||
|
||
export function parseTaskResult(ctx, body) {
|
||
const data = body.data || {};
|
||
const statuses = { submitted: "SUBMITTED", processing: "IN_PROGRESS", succeed: "SUCCESS", failed: "FAILURE" };
|
||
const status = statuses[data.task_status];
|
||
if (!status) throw new Error("unknown task status: " + data.task_status);
|
||
const videos = status === "SUCCESS" && data.task_result && data.task_result.videos ? data.task_result.videos : [];
|
||
const result = { code: body.code || 0, taskId: data.task_id, status: status, reason: data.task_status_msg || "" };
|
||
if (videos.length && videos[0].url) result.url = videos[0].url;
|
||
const units = Number.parseFloat(data.final_unit_deduction || "");
|
||
if (Number.isFinite(units) && units > 0) {
|
||
result.completionTokens = Math.ceil(units);
|
||
result.totalTokens = Math.ceil(units);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function artifactData(ctx) {
|
||
const data = (ctx && ctx.data) || {};
|
||
if (data.data && typeof data.data === "object" && data.data.task_id && Object.prototype.hasOwnProperty.call(data.data, "data")) return data.data.data || {};
|
||
return data;
|
||
}
|
||
|
||
function artifactVideoURL(ctx) {
|
||
const result = (artifactData(ctx).data || {}).task_result || {};
|
||
const videos = Array.isArray(result.videos) ? result.videos : [];
|
||
return videos.length ? String(videos[0].url || "").trim() : "";
|
||
}
|
||
|
||
export function listArtifacts(task) {
|
||
return task.status === "SUCCESS" && artifactVideoURL(task) ? [{ key: "video", type: "video" }] : [];
|
||
}
|
||
|
||
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");
|
||
return { url: url, method: ctx.clientRequest.method, credentialless: true };
|
||
}
|
||
|
||
export function extractUsageOnComplete(_task, _taskResult, body) {
|
||
const data = (body && body.data) || {};
|
||
if (
|
||
!Object.prototype.hasOwnProperty.call(data, "final_unit_deduction") ||
|
||
data.final_unit_deduction === undefined ||
|
||
data.final_unit_deduction === null ||
|
||
data.final_unit_deduction === ""
|
||
) {
|
||
return null;
|
||
}
|
||
const units = Number.parseFloat(data.final_unit_deduction);
|
||
if (!Number.isFinite(units)) return null;
|
||
return { units: units };
|
||
}
|
||
|
||
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(ctx.model);
|
||
if (!model) throw new Error("model is required");
|
||
if (req.input !== undefined && typeof req.input !== "string" && !Array.isArray(req.input)) throw new Error("input must be a string or array");
|
||
if (req.images !== undefined && !Array.isArray(req.images)) throw new Error("images must be an array");
|
||
if (req.metadata !== undefined && (!req.metadata || typeof req.metadata !== "object" || Array.isArray(req.metadata)))
|
||
throw new Error("metadata must be an object");
|
||
const input = responsesInput(req);
|
||
const prompt = input.prompt || trimmed(req.prompt);
|
||
const images = [];
|
||
for (const image of [req.image, req.input_reference].concat(req.images || [], input.images)) {
|
||
if (trimmed(image) && !images.includes(trimmed(image))) images.push(trimmed(image));
|
||
}
|
||
if (!prompt && images.length === 0) throw new Error("input is required");
|
||
const metadata = Object.assign({}, req.metadata || {});
|
||
if (Object.prototype.hasOwnProperty.call(req, "mode")) metadata.mode = req.mode;
|
||
metadata.mode = resolveKlingMode(model, metadata.mode);
|
||
if (images.length > 1 && !metadata.image_tail) metadata.image_tail = images[1];
|
||
const requestBody = { model: model, prompt: prompt, metadata: metadata };
|
||
if (images.length) requestBody.image = images[0];
|
||
if (Object.prototype.hasOwnProperty.call(req, "seconds")) requestBody.duration = req.seconds;
|
||
else if (Object.prototype.hasOwnProperty.call(req, "duration")) requestBody.duration = req.duration;
|
||
if (Object.prototype.hasOwnProperty.call(req, "size")) requestBody.size = req.size;
|
||
return { kind: "submit", model: model, action: images.length ? "image_to_video" : "text_to_video", requestBody: requestBody };
|
||
},
|
||
renderEvents: function (ctx, task, previousState) {
|
||
const status = String(task.status || "UNKNOWN").toUpperCase();
|
||
const value = Number(String(task.progress || "").replace("%", ""));
|
||
const progress = Number.isFinite(value) && value >= 0 && value <= 100 ? value : null;
|
||
const state = { status: status, progress: progress };
|
||
if (status === "SUCCESS") {
|
||
const text = responsesVideoText(ctx);
|
||
const events = previousState && previousState.status === status ? [] : [{ type: "output", data: text }];
|
||
return { events: events, state: state, done: true };
|
||
}
|
||
if (status === "FAILURE")
|
||
return { events: [{ type: "error", code: "task_failed", message: task.fail_reason || "task failed" }], state: state, done: true };
|
||
if (previousState && previousState.status === status && previousState.progress === progress) return { events: [], state: state, done: false };
|
||
const event = { type: "progress", message: status.toLowerCase() };
|
||
if (progress !== null) event.progress = progress;
|
||
return { events: [event], state: state, done: false };
|
||
},
|
||
renderFinal: function (ctx, _task) {
|
||
return {
|
||
output: [
|
||
{
|
||
type: "message",
|
||
status: "completed",
|
||
role: "assistant",
|
||
content: [{ type: "output_text", text: responsesVideoText(ctx), annotations: [], logprobs: [] }],
|
||
},
|
||
],
|
||
metadata: { vendor: "kling" },
|
||
};
|
||
},
|
||
},
|
||
openai_video: {
|
||
decodeRequest: function (ctx) {
|
||
if (!ctx.body || (ctx.body.kind !== "json" && ctx.body.kind !== "multipart")) throw new Error("JSON or multipart body required");
|
||
let req;
|
||
let hasInputReferenceFile = false;
|
||
if (ctx.body.kind === "json") {
|
||
if (!ctx.body.value || Array.isArray(ctx.body.value)) throw new Error("JSON object required");
|
||
req = Object.assign({}, ctx.body.value);
|
||
} else {
|
||
const first = function (name) {
|
||
const values = (ctx.body.fields || {})[name] || [];
|
||
if (values.length > 1) throw new Error(name + " must be provided once");
|
||
return values[0];
|
||
};
|
||
req = {};
|
||
const fields = ctx.body.fields || {};
|
||
for (const name of Object.keys(fields)) {
|
||
req[name] = first(name);
|
||
}
|
||
for (const file of ctx.body.files || []) {
|
||
if (file.field !== "input_reference") throw new Error("unexpected file field: " + file.field);
|
||
if (hasInputReferenceFile) throw new Error("input_reference must be provided once");
|
||
hasInputReferenceFile = true;
|
||
}
|
||
if (req.metadata !== undefined) {
|
||
let parsed;
|
||
try {
|
||
parsed = JSON.parse(req.metadata);
|
||
} catch (e) {
|
||
throw new Error("metadata must be a JSON object string");
|
||
}
|
||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("metadata must be a JSON object string");
|
||
req.metadata = parsed;
|
||
}
|
||
if (req.seconds !== undefined) req.seconds = Number(req.seconds);
|
||
else if (req.duration !== undefined) req.seconds = Number(req.duration);
|
||
}
|
||
const seconds = req.seconds === undefined ? req.duration : req.seconds;
|
||
if (seconds !== undefined) req.duration = validateKlingDuration(seconds);
|
||
else req.duration = 5;
|
||
if (hasInputReferenceFile) {
|
||
req.image = { __fileRef: "request_file:input_reference", encoding: "base64", maxBytes: 10485760 };
|
||
} else {
|
||
const image = trimmed(req.input_reference || req.image);
|
||
if (image) req.image = image;
|
||
}
|
||
const model = ctx.model || req.model || "kling-v1";
|
||
const metadata = req.metadata || {};
|
||
req.mode = resolveKlingMode(model, req.mode || metadata.mode);
|
||
const hasImage = hasKlingImage(req, hasInputReferenceFile);
|
||
return {
|
||
kind: "submit",
|
||
model: ctx.model,
|
||
action: hasImage ? "image_to_video" : "text_to_video",
|
||
requestBody: Object.assign({}, req, { model: ctx.model }),
|
||
};
|
||
},
|
||
render: function (ctx, task) {
|
||
const response = task.data || {};
|
||
const data = response.data || {};
|
||
const statusMap = { NOT_START: "queued", SUBMITTED: "queued", QUEUED: "queued", IN_PROGRESS: "in_progress", SUCCESS: "completed", FAILURE: "failed" };
|
||
const output = {
|
||
id: task.task_id,
|
||
object: "video",
|
||
model: "",
|
||
status: statusMap[task.status] || "unknown",
|
||
progress: Number(String(task.progress || "0").replace("%", "")),
|
||
created_at: data.created_at || 0,
|
||
};
|
||
if (data.updated_at) output.completed_at = data.updated_at;
|
||
const videos = data.task_result && data.task_result.videos ? data.task_result.videos : [];
|
||
if (videos.length) {
|
||
if (videos[0].duration) output.seconds = videos[0].duration;
|
||
}
|
||
if (response.code !== 0 && response.message) output.error = { message: response.message, code: String(response.code) };
|
||
if (data.task_status === "failed") output.error = { message: data.task_status_msg, code: "" };
|
||
return output;
|
||
},
|
||
},
|
||
};
|