Files
new-api/plugins/tasks/kling/plugin.js
T
CaIon 6c22550ea3 feat(task): resolve channel-mapped aliases and case variants for plugin models
Channel model_mapping keys exposed in a channel's model list now act as
first-class aliases for task-plugin models across the whole line:

- Derived alias view (model/task_model_alias.go): built from enabled
  channels' model_mapping, chain-following with cycle detection, declared
  names always win, cross-plugin conflicts dropped. Rebuilt on channel
  cache refresh, registry generation change, and a 60s TTL.
- Request path: PinTaskPluginEndpoint resolves declared-name case folds
  and mapping aliases before endpoint lookup (never rewriting the body
  until the endpoint is claimed), pins with MappedModel, and the decode
  contract accepts alias echoes without loosening model ownership for
  normal pins. Legacy /v1/tasks submit folds case variants the same way.
  Fixes aliases on POST /v1/responses silently falling through to the
  main relay against task channels.
- Mapping order: ModelMappedHelper now runs before the plugin submit
  hook builds and caches the upstream body, so channel model_mapping
  actually reaches the upstream request. Plugins receive the mapped
  name as ctx.upstreamModel in both decode and submit contexts.
- Billing: identity stays the origin name; when the alias has no tiered
  expression, the selected channel's mapping tail expression applies.
  Pricing page and billing-expr smoke tests resolve aliases to the
  owning plugin's usage schema.
- Case folding: ASCII-only fold with exact-match priority; same-plugin
  and cross-plugin fold collisions rejected at registration.
- Plugins: model-keyed rate tables, req_key derivation, and combo
  validation in doubao/kling/jimeng/hailuo/vidu/sunoapi now key on
  ctx.upstreamModel || ctx.model; render/echo paths keep ctx.model.
2026-08-30 19:13:51 +08:00

480 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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 315s 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(ctx.upstreamModel || 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.upstreamModel || 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;
},
},
};