Files
new-api-custom/plugins/tasks/doubao/plugin.js
T

1212 lines
58 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.
// Seedance capabilities from the Ark model list, not prices: the output
// resolutions each model offers, the longest output duration in seconds
// (duration -1 lets Seedance 1.5 pro and 2.x pick a length up to it), whether
// it accepts reference video input (Seedance 2.x prices video-to-video tokens
// separately) and whether audio output is priced separately (Seedance 1.5 pro;
// generate_audio defaults to true). Seedance 1.0 lite is no longer listed and
// keeps the 1.0 pro tiers.
const VIDEO_MODELS = {
"doubao-seedance-1-0-pro-250528": { resolutions: ["480p", "720p", "1080p"], maxDuration: 12 },
"doubao-seedance-1-0-lite-t2v": { resolutions: ["480p", "720p", "1080p"], maxDuration: 12 },
"doubao-seedance-1-0-lite-i2v": { resolutions: ["480p", "720p", "1080p"], maxDuration: 12 },
"doubao-seedance-1-5-pro-251215": { resolutions: ["480p", "720p", "1080p"], maxDuration: 12, audio: true },
"doubao-seedance-2-0-260128": { resolutions: ["480p", "720p", "1080p", "4k"], maxDuration: 15, videoInput: true },
"doubao-seedance-2-0-fast-260128": { resolutions: ["480p", "720p"], maxDuration: 15, videoInput: true },
"doubao-seedance-2-0-mini-260615": { resolutions: ["480p", "720p"], maxDuration: 15, videoInput: true },
"doubao-seedance-2-5-260628": { resolutions: ["480p", "720p", "1080p"], maxDuration: 30, videoInput: true },
};
// Every Ark resolution tier; an endpoint ID reached through channel mapping
// without a declared profile keeps them all and the longest Seedance duration.
const SEEDANCE_RESOLUTIONS = ["480p", "720p", "1080p", "4k"];
const DEFAULT_VIDEO_PROFILE = { resolutions: SEEDANCE_RESOLUTIONS, maxDuration: 30, videoInput: true };
// Protocol capabilities from the Ark image generation API reference, not prices.
// presets: accepted `size` resolution tiers; minPixels/maxPixels bound `WxH` sizes.
// outputFormat/tools/fastPromptMode mirror the per-model support lists in the API
// reference and were confirmed by live 400s; background marks the models whose
// documented transparency combination rules are enforced locally.
const IMAGE_MODELS = {
"doubao-seedream-5-0-pro-260628": {
presets: ["1K", "1.5K", "2K"],
minPixels: 921600,
maxPixels: 4624220,
maxReferenceImages: 10,
sequential: false,
layers: true,
outputFormat: true,
background: true,
tools: false,
fastPromptMode: true,
},
"doubao-seedream-5-0-flash-260915": {
presets: ["1K", "1.5K", "2K"],
minPixels: 921600,
maxPixels: 4624220,
maxReferenceImages: 10,
sequential: false,
layers: true,
outputFormat: true,
background: true,
tools: false,
fastPromptMode: false,
},
"doubao-seedream-5-0-lite-260128": {
presets: ["2K", "3K", "4K"],
minPixels: 3686400,
maxPixels: 16777216,
maxReferenceImages: 14,
sequential: true,
layers: false,
outputFormat: true,
background: false,
tools: true,
fastPromptMode: false,
},
"doubao-seedream-5-0-260128": {
presets: ["2K", "3K", "4K"],
minPixels: 3686400,
maxPixels: 16777216,
maxReferenceImages: 14,
sequential: true,
layers: false,
outputFormat: true,
background: false,
tools: true,
fastPromptMode: false,
},
"doubao-seedream-4-5-251128": {
presets: ["2K", "4K"],
minPixels: 3686400,
maxPixels: 16777216,
maxReferenceImages: 14,
sequential: true,
layers: false,
outputFormat: false,
background: false,
tools: false,
fastPromptMode: false,
},
"doubao-seedream-4-0-250828": {
presets: ["1K", "2K", "4K"],
minPixels: 921600,
maxPixels: 16777216,
maxReferenceImages: 14,
sequential: true,
layers: false,
outputFormat: false,
background: false,
tools: false,
fastPromptMode: true,
},
};
const IMAGE_RESOLUTIONS = ["1K", "1.5K", "2K", "3K", "4K"];
// Square pixel area of each preset, used only for the submit-time tier estimate.
const IMAGE_PRESET_PIXELS = { "1K": 1048576, "1.5K": 2359296, "2K": 4194304, "3K": 9437184, "4K": 16777216 };
// Endpoint IDs and newer models reach the image route without a profile; accept every documented option.
const DEFAULT_IMAGE_PROFILE = {
presets: IMAGE_RESOLUTIONS,
minPixels: 921600,
maxPixels: 16777216,
maxReferenceImages: 14,
sequential: true,
layers: true,
outputFormat: true,
background: true,
tools: true,
fastPromptMode: true,
};
const LAYER_SIZE_PRESETS = ["1K", "1.5K", "2K"];
const IMAGE_ACTIONS = ["text_to_image", "image_to_image"];
// Official Seedream pricing boundary: every output image at or below 2.61
// megapixels (1.5K and below) is priced separately from images above it.
const IMAGE_TIER_MAX_PIXELS = 2610000;
// Documented output ceiling: 15 images per group request, or one base image plus 16 layers.
const MAX_IMAGE_OUTPUTS = 17;
// Documented reference-image ceiling across Seedream models; bounds usage.input_images.
const MAX_REFERENCE_IMAGES = 14;
// Documented per-image size ceiling for reference images.
const MAX_INPUT_IMAGE_BYTES = 31457280;
// Base64 references must be `data:image/<lowercase format>;base64,<payload>`.
const IMAGE_DATA_URI = /^data:image\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/]+={0,2}$/;
// Responses fields copied verbatim into the Ark body. `background` is excluded:
// on /v1/responses it is the host's boolean background-execution flag, not Ark's
// string transparency option. `tools` is filtered separately.
const IMAGE_REQUEST_KEYS = [
"size",
"seed",
"guidance_scale",
"response_format",
"watermark",
"optimize_prompt",
"optimize_prompt_options",
"sequential_image_generation",
"sequential_image_generation_options",
"output_format",
"layer_decomposition",
];
const IMAGE_UNIT_LABEL = { en: "image", zh: "张", "zh-TW": "張", fr: "image", ja: "枚", ru: "изображение", vi: "ảnh" };
// Each output image is billed at its own pixel tier (official pricing bills
// layers individually). Estimated at submit from the requested size and count;
// settled from data[].size per successful image.
const IMAGE_USAGE_SCHEMA = {
// Successful output images at or below IMAGE_TIER_MAX_PIXELS.
images_up_to_1_5k: {
type: "number",
unit: "count",
unitLabel: IMAGE_UNIT_LABEL,
description: { en: "Image generation unit price (1.5K and below)", zh: "图片生成单价(1.5K 及以下)" },
},
// Successful output images above IMAGE_TIER_MAX_PIXELS.
images_above_1_5k: {
type: "number",
unit: "count",
unitLabel: IMAGE_UNIT_LABEL,
description: { en: "Image generation unit price (above 1.5K)", zh: "图片生成单价(1.5K 以上)" },
},
// Reference images: request count at submit, usage.input_images on completion.
// Seedream 5.0 pro charges from the second image; the expression carries that
// rule, for example max(u("input_images") - 1, 0) * price.
input_images: {
type: "number",
unit: "count",
unitLabel: IMAGE_UNIT_LABEL,
description: { en: "Input image unit price", zh: "输入图片单价" },
},
// Layer decomposition outputs (base image plus layers) are priced per layer at their own rate.
layer_decomposition: {
type: "boolean",
description: { en: "Whether layer decomposition is enabled", zh: "是否开启图层拆分" },
},
};
// Models whose smallest output exceeds IMAGE_TIER_MAX_PIXELS only ever produce
// images above 1.5K, so their pricing has a single output tier.
function hasLowerImageTier(profile) {
return profile.minPixels <= IMAGE_TIER_MAX_PIXELS;
}
// Usage facts for one Seedream capability profile: the pricing table then
// lists the lower tier and layer decomposition only for models that offer them.
// Remaining fields keep their released keys.
function seedreamUsageSchema(profile) {
const schema = {};
if (hasLowerImageTier(profile)) {
schema.images_up_to_1_5k = IMAGE_USAGE_SCHEMA.images_up_to_1_5k;
schema.images_above_1_5k = IMAGE_USAGE_SCHEMA.images_above_1_5k;
} else {
schema.images_above_1_5k = { ...IMAGE_USAGE_SCHEMA.images_above_1_5k, description: { en: "Image generation unit price", zh: "图片生成单价" } };
}
schema.input_images = IMAGE_USAGE_SCHEMA.input_images;
if (profile.layers) schema.layer_decomposition = IMAGE_USAGE_SCHEMA.layer_decomposition;
return schema;
}
// Image facts always carry all four released keys, so expressions saved
// before the per-model profiles keep their charge.
function seedreamUsageFacts(lower, higher, inputImages, layered) {
return { images_up_to_1_5k: lower, images_above_1_5k: higher, input_images: inputImages, layer_decomposition: layered };
}
function seedreamUsageExamples(profile) {
const examples = [{ label: "2K · 1 张", facts: seedreamUsageFacts(0, 1, 0, false) }];
if (hasLowerImageTier(profile)) examples.push({ label: "1K · 1 张 · 2 张参考图", facts: seedreamUsageFacts(1, 0, 2, false) });
else examples.push({ label: "2K · 1 张 · 2 张参考图", facts: seedreamUsageFacts(0, 1, 2, false) });
if (profile.sequential) examples.push({ label: "2K · 4 张组图", facts: seedreamUsageFacts(0, 4, 0, false) });
if (profile.layers) examples.push({ label: "图层拆分 · 2K 底图 + 4 层 1.5K", facts: seedreamUsageFacts(4, 1, 1, true) });
// Examples are complete vectors over the profile schema alone.
const keys = Object.keys(seedreamUsageSchema(profile));
return examples.map((example) => ({ label: example.label, facts: Object.fromEntries(keys.map((key) => [key, example.facts[key]])) }));
}
// Usage facts for one Seedance capability profile: the pricing table then
// lists only the resolutions and input kinds the model offers.
function seedanceUsageSchema(profile) {
const resolutionLabels = {};
for (const resolution of profile.resolutions) resolutionLabels[resolution] = { en: resolution, zh: resolution };
const schema = {
// Upstream billing tokens (estimated at submit, actual on completion).
tokens: {
type: "number",
unit: "token",
description: { en: "Billing token unit price", zh: "计费 Token 单价" },
},
// Output video resolution; Seedance token unit price varies by resolution tier.
resolution: {
enum: profile.resolutions,
enumLabels: resolutionLabels,
description: { en: "Output video resolution", zh: "输出视频分辨率" },
},
};
// Whether the request includes reference video input; Seedance 2.x prices video-to-video tokens at a lower unit rate.
if (profile.videoInput) {
schema.video_input = {
enum: ["none", "video"],
enumLabels: { none: { en: "No reference video", zh: "无参考视频" }, video: { en: "With reference video", zh: "有参考视频" } },
description: { en: "Reference video input", zh: "参考视频输入" },
};
}
// Seedance 1.5 pro prices videos with and without audio differently.
if (profile.audio) {
schema.generate_audio = {
type: "boolean",
description: { en: "Whether audio is generated", zh: "是否生成音频" },
};
}
return schema;
}
// Display examples for a token-priced profile: 5-second videos at each tier
// from the official Ark formula tokens = (input + output seconds) × W × H ×
// 24 / 1024 with 16:9 max-pixel sizes, cross-checked against Volcengine price
// examples, plus the reference-video and silent variants the profile prices.
function seedanceUsageExamples(profile) {
const specs = profile.resolutions.map((resolution) => ({
label: resolution + " · 5s" + (profile.audio ? " · 有声" : ""),
resolution: resolution,
seconds: 5,
}));
if (profile.videoInput) specs.push({ label: "720p · 5s (+4s 输入视频)", resolution: "720p", seconds: 9, video_input: "video" });
if (profile.audio) specs.push({ label: "720p · 5s · 无声", resolution: "720p", seconds: 5, generate_audio: false });
return specs.map((spec) => {
const facts = { tokens: Math.round(estimateTokens(spec.seconds, spec.resolution)), resolution: spec.resolution };
if (profile.videoInput) facts.video_input = spec.video_input || "none";
if (profile.audio) facts.generate_audio = spec.generate_audio !== false;
return { label: spec.label, facts: facts };
});
}
// One usage profile per distinct pricing shape of a capability table: models
// whose schema and examples are identical share a profile.
function capabilityUsageProfiles(models, usageSchema, usageExamples) {
const profiles = [];
for (const model of Object.keys(models)) {
const schema = usageSchema(models[model]);
const examples = usageExamples(models[model]);
const shape = JSON.stringify([schema, examples]);
let profile = profiles.find((entry) => entry.shape === shape);
if (!profile) {
profile = { shape: shape, models: [], schema: schema, examples: examples };
profiles.push(profile);
}
profile.models.push(model);
}
return profiles.map((profile) => ({ models: profile.models, schema: profile.schema, examples: profile.examples }));
}
export const meta = {
apiVersion: 1,
key: "doubao",
name: "Doubao",
icon: "Doubao.Color",
description: {
en: "Volcengine Doubao Seedance video generation and Seedream image generation",
zh: "火山引擎豆包 Seedance 视频生成与 Seedream 图片生成",
},
version: "1.3.0",
author: { name: "QuantumNous" },
channelTypes: [54, 45], // VolcEngine-type channels serve Ark video models with the same wire format
models: Object.keys(VIDEO_MODELS).concat(Object.keys(IMAGE_MODELS)),
fetchMode: "per_task",
upstreams: ["vendor", "new_api"],
requiredCapabilities: ["duration-auto@1"],
usageSchema: seedanceUsageSchema(DEFAULT_VIDEO_PROFILE),
usageExamples: seedanceUsageExamples(DEFAULT_VIDEO_PROFILE),
usageProfiles: capabilityUsageProfiles(VIDEO_MODELS, seedanceUsageSchema, seedanceUsageExamples).concat(
capabilityUsageProfiles(IMAGE_MODELS, seedreamUsageSchema, seedreamUsageExamples)
),
routes: [
{ method: "POST", path: "/doubao/api/v3/contents/generations/tasks", type: "submit", decode: "createTask", render: "taskCreated" },
{ method: "GET", path: "/doubao/api/v3/contents/generations/tasks/:task_id", type: "query", render: "taskStatus" },
// The vendor image API answers synchronously and has no task to re-query;
// the complete response is delivered once and never persisted.
{ method: "POST", path: "/doubao/api/v3/images/generations", type: "submit", decode: "createImage", render: "imageCreated", retainResult: false },
],
protocols: [
{ name: "openai_responses", supports: ["stream", "sync", "background"] },
{ name: "openai_video", models: Object.keys(VIDEO_MODELS) },
{ name: "openai_image", models: Object.keys(IMAGE_MODELS) },
],
};
function trimmed(value) {
return String(value || "").trim();
}
// Another New API gateway serves the Ark wire format only on this plugin's
// prefixed native routes; Ark itself serves the unprefixed paths.
function apiRoot(ctx) {
return ctx.baseUrl + (ctx.upstream && ctx.upstream.kind === "new_api" ? "/doubao" : "");
}
function objectValue(value, name) {
if (value === undefined || value === null) return {};
if (typeof value !== "object" || Array.isArray(value)) throw new Error(name + " must be an object");
return value;
}
function draftTaskIds(content) {
const ids = [];
if (!Array.isArray(content)) return ids;
for (const item of content) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
if (item.type !== "draft_task") continue;
const draft = item.draft_task;
if (!draft || typeof draft !== "object" || Array.isArray(draft)) continue;
const id = trimmed(draft.id);
if (id) ids.push(id);
}
return ids;
}
function rewriteDraftTaskContent(content, originTasks) {
if (!Array.isArray(content)) return content;
return content.map(function (item) {
if (!item || typeof item !== "object" || Array.isArray(item) || item.type !== "draft_task") return item;
const draft = item.draft_task;
if (!draft || typeof draft !== "object" || Array.isArray(draft) || !trimmed(draft.id)) return item;
const publicId = trimmed(draft.id);
let upstream = "";
if (Array.isArray(originTasks)) {
for (const task of originTasks) {
if (task && task.taskId === publicId) {
upstream = trimmed(task.upstreamTaskId);
break;
}
}
}
if (!upstream) throw new Error("origin task is unavailable");
return Object.assign({}, item, { draft_task: Object.assign({}, draft, { id: upstream }) });
});
}
function normalizeResolution(value) {
const raw = trimmed(value).toLowerCase();
if (["480p", "720p", "1080p", "4k"].includes(raw)) return raw;
const parts = raw.replace("*", "x").split("x");
if (parts.length !== 2) return "720p";
const max = Math.max(Number(parts[0]), Number(parts[1]));
if (max >= 3840) return "4k";
if (max >= 1920) return "1080p";
if (max >= 1280) return "720p";
return "480p";
}
function hasVideo(content) {
return Array.isArray(content) && content.some((item) => item && (item.type === "video_url" || Object.prototype.hasOwnProperty.call(item, "video_url")));
}
// Capability profile of the executing Seedance model. Channel mapping may send
// a declared model to an Ark endpoint ID; the declared name still describes it.
function videoProfile(ctx) {
for (const model of [ctx && ctx.upstreamModel, ctx && ctx.model].map(trimmed)) {
if (Object.prototype.hasOwnProperty.call(VIDEO_MODELS, model)) return VIDEO_MODELS[model];
}
return DEFAULT_VIDEO_PROFILE;
}
// Resolution tier the reservation is estimated at: the requested tier or the
// tier of a WxH size, rejecting tiers the model does not offer. A request that
// leaves the resolution to Ark reserves the model's highest tier up to 1080p;
// completion overlays the delivered resolution.
function videoResolution(ctx) {
const req = ctx.requestBody || {};
const metadata = req.metadata || {};
const profile = videoProfile(ctx);
const raw = trimmed(metadata.resolution || req.size).toLowerCase();
const recognized = SEEDANCE_RESOLUTIONS.includes(raw) || raw.replace("*", "x").split("x").length === 2;
if (!recognized) return profile.resolutions.includes("1080p") ? "1080p" : profile.resolutions[profile.resolutions.length - 1];
const resolution = normalizeResolution(raw);
if (!profile.resolutions.includes(resolution))
throw new Error(trimmed(ctx.upstreamModel || ctx.model) + " resolution must be one of " + profile.resolutions.join(", "));
return resolution;
}
// Max-pixel 16:9 dimensions per resolution tier. Used when ratio is absent or
// adaptive so the submit-time estimate overestimates rather than underestimates.
// Official Ark formula: tokens = seconds × width × height × 24 / 1024.
// Video input duration is omitted; extractUsageOnComplete overlays the real bill.
function resolutionMaxPixels(resolution) {
if (resolution === "480p") return [854, 480];
if (resolution === "1080p") return [1920, 1080];
if (resolution === "4k") return [3840, 2160];
return [1280, 720];
}
function estimateTokens(seconds, resolution) {
const dims = resolutionMaxPixels(resolution);
return (seconds * dims[0] * dims[1] * 24) / 1024;
}
function videoInputRatio(model, resolution, content) {
const video = hasVideo(content);
const res = trimmed(resolution).toLowerCase();
if (model === "doubao-seedance-2-5-260628") {
if (res === "1080p") return video ? 7.0 / 10.7 : 11.7 / 10.7;
return video ? 42 / 70 : 1;
}
if (model === "doubao-seedance-2-0-260128") {
if (res === "1080p") return video ? 31 / 46 : 51 / 46;
if (res === "4k") return video ? 16 / 46 : 26 / 46;
return video ? 28 / 46 : 1;
}
if (model === "doubao-seedance-2-0-fast-260128") return video ? 22 / 37 : 1;
if (model === "doubao-seedance-2-0-mini-260615") return video ? 14 / 23 : 1;
return 1;
}
// Channel model mapping may send a declared Seedream model to an Ark endpoint
// ID; the declared name the client sent still describes that endpoint.
function imageModelCandidates(ctx) {
const req = (ctx && ctx.requestBody) || {};
return [ctx && ctx.upstreamModel, ctx && ctx.model, req.model].map(trimmed).filter(Boolean);
}
function imageProfile(ctx) {
for (const model of imageModelCandidates(ctx)) {
if (Object.prototype.hasOwnProperty.call(IMAGE_MODELS, model)) return IMAGE_MODELS[model];
}
return DEFAULT_IMAGE_PROFILE;
}
// Image work is identified by a declared model, or by the decode action for
// endpoint IDs that reach the image route without any declared name.
function imageTask(ctx) {
return imageModelCandidates(ctx).some((model) => Object.prototype.hasOwnProperty.call(IMAGE_MODELS, model)) || IMAGE_ACTIONS.includes(ctx && ctx.action);
}
function imageSizePixels(value) {
const match = /^(\d+)\s*[xX×*]\s*(\d+)$/.exec(trimmed(value));
if (!match) return null;
const width = Number(match[1]);
const height = Number(match[2]);
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) return null;
return { width: width, height: height, pixels: width * height };
}
// Validates the request `size` against the documented per-model presets and
// pixel ranges, normalizes accepted spellings (`2k` -> `2K`, `2048*2048` ->
// `2048x2048`) and returns the pixel area used for the submit-time tier
// estimate. `pixels` is null when the output size is unknown (`auto`), which
// the estimate treats as the higher tier; completion overlays the real sizes.
function imageSize(size, profile, layered) {
// Documented defaults: 2K / 2048x2048 for image generation, `auto` for layer decomposition.
if (size === undefined) return { size: undefined, pixels: layered ? null : IMAGE_PRESET_PIXELS["2K"] };
if (typeof size !== "string") throw new Error("size must be a string");
const preset = trimmed(size).toUpperCase();
if (preset === "AUTO") {
if (!layered) throw new Error("size auto is only supported with layer decomposition");
return { size: "auto", pixels: null };
}
if (IMAGE_RESOLUTIONS.includes(preset)) {
const allowed = layered ? LAYER_SIZE_PRESETS : profile.presets;
if (!allowed.includes(preset)) throw new Error("size must be one of " + allowed.join(", ") + (layered ? ", auto" : " or width x height pixels"));
return { size: preset, pixels: IMAGE_PRESET_PIXELS[preset] };
}
if (layered) throw new Error("layer decomposition sizes must be one of " + LAYER_SIZE_PRESETS.join(", ") + ", auto");
const dims = imageSizePixels(size);
if (!dims) throw new Error("size must be a resolution preset or width x height pixels");
const ratio = dims.width / dims.height;
if (dims.pixels < profile.minPixels || dims.pixels > profile.maxPixels || ratio < 1 / 16 || ratio > 16)
throw new Error("size is outside the model's pixel and aspect-ratio limits");
return { size: dims.width + "x" + dims.height, pixels: dims.pixels };
}
// Builds the upstream /api/v3/images/generations body. Unknown fields pass
// through so the native route mirrors the official API; documented fields are
// type-checked and model capabilities are enforced here. Live probes (2026-09)
// confirmed Ark rejects wrong-typed seed/guidance_scale, output_format on 4.0,
// tools on pro, sequential_image_generation on pro, and non-web_search tools,
// and accepts `optimize_prompt_options: null` as unset.
function convertImage(ctx) {
const req = ctx.requestBody || {};
const model = trimmed(ctx.upstreamModel || req.model);
if (!model) throw new Error("model is required");
const profile = imageProfile(ctx);
const body = {};
for (const key of Object.keys(req)) {
if (key === "model" || key === "stream" || req[key] === undefined) continue;
body[key] = req[key];
}
body.model = model;
for (const key of ["watermark", "optimize_prompt", "layer_decomposition"]) {
if (body[key] !== undefined && typeof body[key] !== "boolean") throw new Error(key + " must be a boolean");
}
const layered = body.layer_decomposition === true;
if (layered && !profile.layers) throw new Error("layer_decomposition is not supported by this model");
if (body.prompt !== undefined && typeof body.prompt !== "string") throw new Error("prompt must be a string");
if (!layered && !trimmed(body.prompt)) throw new Error("prompt is required");
const images = body.image === undefined ? [] : Array.isArray(body.image) ? body.image : [body.image];
for (const image of images) {
// Multipart uploads stay host file placeholders until submit, when the host
// inlines them as data:image/<format>;base64 URLs.
if (image && typeof image === "object" && typeof image.__fileRef === "string" && image.encoding === "dataUrl") continue;
const value = typeof image === "string" ? trimmed(image) : "";
if (!/^https?:\/\//i.test(value) && !IMAGE_DATA_URI.test(value))
throw new Error("image must be an HTTP URL or a data:image/<format>;base64 URL with a lowercase format");
}
if (layered && images.length !== 1) throw new Error("layer decomposition requires exactly one input image");
if (images.length > profile.maxReferenceImages) throw new Error("at most " + profile.maxReferenceImages + " reference images are supported");
// The host caps the upstream JSON response and the persisted task data at
// 1 MiB and serves images through artifact URLs, so it cannot reliably accept
// or persist Base64 image payloads; this route is URL-only.
if (body.response_format !== undefined && body.response_format !== "url") throw new Error("response_format must be url");
if (body.seed !== undefined && !Number.isInteger(body.seed)) throw new Error("seed must be an integer");
if (body.guidance_scale !== undefined && (typeof body.guidance_scale !== "number" || !Number.isFinite(body.guidance_scale)))
throw new Error("guidance_scale must be a number");
if (body.output_format !== undefined) {
if (body.output_format !== "png" && body.output_format !== "jpeg") throw new Error("output_format must be png or jpeg");
if (!profile.outputFormat) throw new Error("output_format is not supported by this model");
}
// The API reference lists `background` for 5.0 pro and flash only, but a live
// probe showed lite accepting and ignoring it, so other models forward the
// value and only pro and flash's documented combination rules are enforced locally.
if (body.background !== undefined) {
if (body.background !== "opaque" && body.background !== "transparent") throw new Error("background must be opaque or transparent");
if (profile.background && body.background === "transparent" && images.length !== 1)
throw new Error("background transparent requires exactly one input image");
if (profile.background && body.background === "transparent" && body.output_format === "jpeg")
throw new Error("background transparent cannot be combined with output_format jpeg");
}
if (body.tools !== undefined) {
if (!Array.isArray(body.tools) || body.tools.some((tool) => !tool || typeof tool !== "object" || Array.isArray(tool) || !trimmed(tool.type)))
throw new Error("tools must be an array of objects with a string type");
if (!profile.tools) throw new Error("tools are not supported by this model");
}
// JSON null is forwarded as-is: Ark treats it as an unset object (confirmed live).
const promptOptions = objectValue(body.optimize_prompt_options, "optimize_prompt_options");
if (promptOptions.mode !== undefined) {
if (promptOptions.mode !== "standard" && promptOptions.mode !== "fast") throw new Error("optimize_prompt_options.mode must be standard or fast");
if (promptOptions.mode === "fast" && !profile.fastPromptMode) throw new Error("optimize_prompt_options.mode fast is not supported by this model");
}
const sequential = body.sequential_image_generation;
if (sequential !== undefined && sequential !== "auto" && sequential !== "disabled") throw new Error("sequential_image_generation must be auto or disabled");
if (sequential !== undefined && !profile.sequential) throw new Error("sequential_image_generation is not supported by this model");
const options = objectValue(body.sequential_image_generation_options, "sequential_image_generation_options");
let maxImages = 15;
if (options.max_images !== undefined) {
if (!Number.isInteger(options.max_images) || options.max_images < 1 || options.max_images > 15)
throw new Error("max_images must be an integer between 1 and 15");
maxImages = options.max_images;
}
const size = imageSize(body.size, profile, layered);
if (size.size !== undefined) body.size = size.size;
// Submit-time estimate; extractUsageOnComplete overlays the per-image tiers
// from data[].size and usage.input_images. Unknown output sizes (`auto`)
// reserve the higher tier.
let imageCount = 1;
if (layered) imageCount = MAX_IMAGE_OUTPUTS;
else if (sequential === "auto") imageCount = Math.max(1, Math.min(maxImages, 15 - images.length)); // reference + generated images ≤ 15
const higherTier = size.pixels === null || size.pixels > IMAGE_TIER_MAX_PIXELS;
return {
body: body,
action: images.length ? "image_to_image" : "text_to_image",
facts: seedreamUsageFacts(higherTier ? 0 : imageCount, higherTier ? imageCount : 0, images.length, layered),
};
}
// `data` is an array in the documented response; tolerate an object-shaped
// payload from compatible upstreams by treating it as one entry.
function imageEntries(body) {
const data = body && body.data;
if (Array.isArray(data)) return data.filter((item) => item && typeof item === "object" && !Array.isArray(item));
if (data && typeof data === "object") return [data];
return [];
}
function imageURLEntries(body) {
return imageEntries(body).filter((item) => trimmed(item.url));
}
// Delivered image payloads of a synchronous result. Payloads are counted,
// never entries: failed group members carry only `error`. A
// usage.generated_images count that is fractional, negative, oversized or
// inconsistent with the delivered payloads means the response is not
// understood; the host then keeps the reservation instead of settling on a guess.
function imagePayloads(body) {
const payloads = imageEntries(body).filter((item) => trimmed(item.url) || trimmed(item.b64_json));
if (payloads.length > MAX_IMAGE_OUTPUTS) throw new Error("too many output images");
const count = (body.usage || {}).generated_images;
if (count !== undefined && (!Number.isInteger(count) || count < 0 || count > MAX_IMAGE_OUTPUTS || count !== payloads.length))
throw new Error("invalid upstream generated_images");
return payloads;
}
// Completion facts. Each successful image is counted at its own pixel tier from
// data[].size (official pricing bills layers individually). Payloads without a
// parseable size keep the submit-time tier estimate, except on single-tier
// models where every payload is the upper tier; usage.input_images replaces
// the estimated reference count only when it is a bounded integer.
function imageUsage(body, profile) {
const usage = body.usage || {};
const payloads = imagePayloads(body);
const facts = {};
if (hasLowerImageTier(profile)) {
let lower = 0,
higher = 0,
sized = true;
for (const item of payloads) {
const dims = imageSizePixels(item.size);
if (!dims) {
sized = false;
break;
}
if (dims.pixels > IMAGE_TIER_MAX_PIXELS) higher += 1;
else lower += 1;
}
if (sized) {
facts.images_up_to_1_5k = lower;
facts.images_above_1_5k = higher;
}
} else {
facts.images_above_1_5k = payloads.length;
}
if (Number.isInteger(usage.input_images) && usage.input_images >= 0 && usage.input_images <= MAX_REFERENCE_IMAGES) facts.input_images = usage.input_images;
return facts;
}
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 responsesOutputText(ctx, task) {
const data = artifactData(task);
if (!imageTask(ctx) && !Array.isArray(data.data)) return responsesVideoText(ctx);
const parts = [];
imageURLEntries(data).forEach(function (_, index) {
const artifact = (ctx.artifacts || {})["image-" + (index + 1)];
if (!artifact || !trimmed(artifact.url)) throw new Error("image artifact is unavailable");
parts.push("![Image " + (index + 1) + "](<" + artifact.url + ">)");
});
if (!parts.length) throw new Error("image artifact is unavailable");
return parts.join("\n\n");
}
export const native = {
createTask: function (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");
const model = trimmed(body.model);
if (!model) throw new Error("model is required");
if (body.content !== undefined && !Array.isArray(body.content)) throw new Error("content must be an array");
const content = Array.isArray(body.content) ? body.content : [];
const texts = [];
let hasReference = false;
for (const item of content) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
if (item.type === "text" && typeof item.text === "string") texts.push(item.text);
else hasReference = true;
}
if (!texts.length && !hasReference) throw new Error("content is required");
const requestBody = {
model: model,
prompt: texts
.filter(function (text) {
return trimmed(text);
})
.join("\n"),
metadata: body,
};
const seconds = Number(body.duration);
if (Number.isFinite(seconds) && seconds > 0) requestBody.seconds = seconds;
const intent = { kind: "submit", model: model, action: hasReference ? "image_to_video" : "text_to_video", requestBody: requestBody };
const originTaskIds = draftTaskIds(content);
if (originTaskIds.length) intent.originTaskIds = originTaskIds;
return intent;
},
taskCreated: function (ctx, task) {
const data = task.data && typeof task.data === "object" && !Array.isArray(task.data) ? task.data : {};
return Object.assign({}, data, { id: task.task_id });
},
taskStatus: function (ctx, task) {
if (task.data && typeof task.data === "object" && !Array.isArray(task.data)) return Object.assign({}, task.data, { id: task.task_id });
const statusMap = { NOT_START: "queued", SUBMITTED: "queued", QUEUED: "queued", IN_PROGRESS: "running", SUCCESS: "succeeded", FAILURE: "failed" };
const output = { id: task.task_id, status: statusMap[task.status] || "queued" };
if (task.fail_reason) output.error = { message: task.fail_reason };
return output;
},
createImage: function (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");
const model = trimmed(body.model);
if (!model) throw new Error("model is required");
// The host presents one JSON response after the upstream call completes; upstream SSE is not forwarded.
if (body.stream !== undefined && body.stream !== false)
throw new Error("stream is not supported on this route; the complete JSON response is returned once all images are generated");
const requestBody = Object.assign({}, body, { model: model });
const converted = convertImage({ model: model, requestBody: requestBody });
return { kind: "submit", model: model, action: converted.action, requestBody: requestBody };
},
imageCreated: function (ctx, task) {
return task.data && typeof task.data === "object" && !Array.isArray(task.data) ? task.data : {};
},
error: function (ctx, error) {
return { error: { code: error.code, message: error.message } };
},
};
export function buildSubmitRequest(ctx) {
if (imageTask(ctx)) {
const converted = convertImage(ctx);
return {
url: apiRoot(ctx) + "/api/v3/images/generations",
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: "Bearer " + ctx.apiKey },
body: converted.body,
action: converted.action,
rewriteModel: converted.body.model,
};
}
const req = ctx.requestBody;
const metadata = req.metadata || {};
// Reject output tiers the model does not offer before any quota is reserved.
videoResolution(ctx);
const body = Object.assign({ model: req.model || "", content: [] }, metadata);
const imageContent = [];
const images = Array.isArray(req.images) ? req.images : [];
for (const url of images) imageContent.push({ type: "image_url", image_url: { url: url } });
const metadataContent = Array.isArray(body.content) ? body.content : [];
body.content = imageContent.concat(metadataContent).filter((item) => item && item.type !== "text");
const hasReference = body.content.length > 0;
if (trimmed(req.prompt) || !hasReference) body.content.push({ type: "text", text: req.prompt || "" });
if (Array.isArray(body.content)) body.content = rewriteDraftTaskContent(body.content, ctx.originTasks);
// -1 lets the model pick the length; extractUsage reserves the longest one.
const seconds = Number.parseInt(req.seconds || "", 10);
if (seconds > 0 || seconds === -1) body.duration = seconds;
body.model = ctx.upstreamModel || body.model;
return {
url: apiRoot(ctx) + "/api/v3/contents/generations/tasks",
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: "Bearer " + ctx.apiKey },
body: body,
action: hasReference ? "image_to_video" : "text_to_video",
rewriteModel: body.model,
};
}
export function parseSubmitResponse(ctx, resp) {
if (imageTask(ctx)) {
const body = resp.body;
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("invalid image generation response");
if (body.error && typeof body.error === "object")
throw new Error((trimmed(body.error.code) || "upstream_error") + ": " + (trimmed(body.error.message) || "image generation failed"));
const urls = imageURLEntries(body);
if (!urls.length) throw new Error("image generation returned no images");
// The synchronous API has no vendor task; keep the host-issued public id.
const taskId = trimmed(ctx.publicTaskId);
if (!taskId) throw new Error("missing gateway task id");
return { taskId: taskId, taskData: body, immediate: { status: "SUCCESS", progress: "100%", url: trimmed(urls[0].url) } };
}
if (!resp.body || !resp.body.id) throw new Error("task_id is empty");
return { taskId: resp.body.id, taskData: resp.body };
}
export function extractUsage(ctx) {
if (imageTask(ctx)) {
const facts = convertImage(ctx).facts;
// Legacy per-call pricing multiplies the price by every ratio returned
// here, so the reservation ratio is the total requested output count
// alone; task expressions read the tiered facts and the reference count.
if (ctx.usagePurpose === "billing_ratios") return { image_count: facts.images_up_to_1_5k + facts.images_above_1_5k };
return facts;
}
const req = ctx.requestBody || {};
const metadata = req.metadata || {};
if (ctx.usagePurpose === "billing_ratios") {
const ratio = videoInputRatio(ctx.upstreamModel || ctx.model, metadata.resolution, metadata.content);
return ratio === 1 ? null : { video_input_ratio: ratio };
}
const profile = videoProfile(ctx);
// A missing duration or -1 (the model picks the length; Seedance 2.5
// defaults to it) reserves the longest duration the model can produce.
let seconds = Number(req.seconds || req.duration || metadata.duration || 0);
if (!Number.isFinite(seconds) || seconds <= 0) {
const frames = Number(metadata.frames);
seconds = Number.isFinite(frames) && frames > 0 ? Math.floor(frames / 24) : profile.maxDuration;
}
if (seconds <= 0) seconds = 5;
seconds = Math.min(seconds, 3600);
const resolution = videoResolution(ctx);
const facts = { tokens: estimateTokens(seconds, resolution), resolution: resolution };
// Every model reports video_input, including those whose profile omits it:
// expressions saved when all Seedance models declared the field then keep
// matching their no-video tier.
facts.video_input = hasVideo(metadata.content) ? "video" : "none";
if (profile.audio) facts.generate_audio = metadata.generate_audio !== false;
return facts;
}
export function extractUsageOnSubmit(ctx, body) {
// Only legacy per-call pricing calls this hook, and the host multiplies the
// price by every ratio it returns, so a synchronous image result settles on
// the delivered output count alone. Task expressions settle the tiered
// facts through extractUsageOnComplete instead.
if (!imageTask(ctx)) return null;
const count = imagePayloads(body || {}).length;
return count ? { image_count: count } : {};
}
export function buildQueryRequest(ctx) {
return {
url: apiRoot(ctx) + "/api/v3/contents/generations/tasks/" + ctx.taskId,
method: "GET",
headers: { Accept: "application/json", "Content-Type": "application/json", Authorization: "Bearer " + ctx.apiKey },
};
}
export function parseTaskResult(ctx, body) {
if (body.status === "pending" || body.status === "queued") return { status: "QUEUED", progress: "10%" };
if (body.status === "processing" || body.status === "running") return { status: "IN_PROGRESS", progress: "50%" };
if (body.status === "succeeded") {
const result = { status: "SUCCESS", progress: "100%", url: body.content && body.content.video_url ? body.content.video_url : "" };
const usage = body.usage || {};
const completionTokens = Number(usage.completion_tokens || 0);
const totalTokens = Number(usage.total_tokens || 0);
if (Number.isFinite(completionTokens) && completionTokens > 0) result.completionTokens = completionTokens;
if (Number.isFinite(totalTokens) && totalTokens > 0) result.totalTokens = totalTokens;
return result;
}
if (body.status === "failed" || body.status === "expired" || body.status === "cancelled") {
const reason = body.error && body.error.message ? body.error.message : body.status;
return { status: "FAILURE", progress: "100%", reason: reason };
}
return { status: "UNKNOWN", reason: "unrecognized status: " + String(body.status || "") };
}
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;
}
export function listArtifacts(task) {
if (task.status !== "SUCCESS") return [];
const data = artifactData(task);
if (imageTask(task) || Array.isArray(data.data)) {
return imageURLEntries(data).map(function (item, index) {
const artifact = { key: "image-" + (index + 1), type: "image" };
if (item.output_format === "png") artifact.mimeType = "image/png";
else if (item.output_format === "jpeg") artifact.mimeType = "image/jpeg";
return artifact;
});
}
const content = data.content || {};
const artifacts = [];
if (trimmed(content.video_url)) artifacts.push({ key: "video", type: "video" });
if (trimmed(content.last_frame_url)) artifacts.push({ key: "last_frame", type: "image", mimeType: "image/png" });
return artifacts;
}
export function buildContentRequest(ctx) {
const data = artifactData(ctx);
let url = "";
if (String(ctx.artifactKey || "").startsWith("image-")) {
const entries = imageURLEntries(data);
const index = entries.findIndex(function (_, position) {
return ctx.artifactKey === "image-" + (position + 1);
});
url = index >= 0 ? trimmed(entries[index].url) : "";
} else {
const content = data.content || {};
const urls = { video: content.video_url, last_frame: content.last_frame_url };
url = trimmed(urls[ctx.artifactKey]);
}
if (!url) throw new Error("artifact_not_found");
return { url: url, method: ctx.clientRequest.method, credentialless: true };
}
export function extractUsageOnComplete(task, taskResult, body) {
if (imageTask(task)) return imageUsage(body || {}, imageProfile(task));
if (!body || body.status !== "succeeded") return {};
const facts = {};
const usage = body.usage || {};
let tokens = Number(usage.completion_tokens);
if (!Number.isFinite(tokens) || tokens <= 0) tokens = Number(usage.total_tokens);
if (Number.isFinite(tokens) && tokens > 0) facts.tokens = tokens;
const content = body.content || {};
const resolution = trimmed(content.resolution || body.resolution).toLowerCase();
if (videoProfile(task).resolutions.includes(resolution)) facts.resolution = resolution;
return facts;
}
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");
const input = responsesInput(req);
if (imageTask({ upstreamModel: ctx.upstreamModel, model: model })) {
const prompt = input.prompt || trimmed(req.prompt);
const listed = Array.isArray(req.image) ? req.image : req.image === undefined ? [] : [req.image];
const images = [];
for (const image of listed.concat(req.images || [], input.images)) {
if (trimmed(image) && !images.includes(trimmed(image))) images.push(trimmed(image));
}
if (!prompt && !images.length) throw new Error("input is required");
const requestBody = { model: model };
if (prompt) requestBody.prompt = prompt;
if (images.length) requestBody.image = images;
for (const key of IMAGE_REQUEST_KEYS) {
if (Object.prototype.hasOwnProperty.call(req, key)) requestBody[key] = req[key];
}
// Responses `background` (boolean background execution) is host-owned and never reaches Ark;
// Ark's string `background` transparency option is available on the native route only.
// Only Ark's documented web_search tool is forwarded; function and other Responses tools are dropped.
if (Array.isArray(req.tools)) {
const tools = req.tools.filter((tool) => tool && typeof tool === "object" && !Array.isArray(tool) && tool.type === "web_search");
if (tools.length) requestBody.tools = tools.map(() => ({ type: "web_search" }));
}
// The model may be a mapped alias; final validation runs after channel selection.
const converted = convertImage({ upstreamModel: ctx.upstreamModel, model: model, requestBody: requestBody });
return { kind: "submit", model: model, action: converted.action, requestBody: requestBody };
}
if (req.metadata !== undefined && (!req.metadata || typeof req.metadata !== "object" || Array.isArray(req.metadata)))
throw new Error("metadata must be an object");
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, "resolution")) metadata.resolution = req.resolution;
else if (req.size && !metadata.resolution) metadata.resolution = normalizeResolution(req.size);
const requestBody = { model: model, prompt: prompt, metadata: metadata };
if (images.length) requestBody.images = images;
if (Object.prototype.hasOwnProperty.call(req, "seconds")) requestBody.seconds = req.seconds;
else if (Object.prototype.hasOwnProperty.call(req, "duration")) requestBody.seconds = req.duration;
if (Object.prototype.hasOwnProperty.call(req, "size")) requestBody.size = req.size;
const intent = { kind: "submit", model: model, action: images.length ? "image_to_video" : "text_to_video", requestBody: requestBody };
const originTaskIds = draftTaskIds(metadata.content);
if (originTaskIds.length) intent.originTaskIds = originTaskIds;
return intent;
},
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 = responsesOutputText(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: responsesOutputText(ctx, task), annotations: [], logprobs: [] }],
},
],
metadata: { vendor: "doubao" },
};
},
},
// OpenAI Images API. The host pins ctx.model, returns the synchronous result
// inline and owns response_format. Requests map onto the same Ark body as the
// native route, so validation and usage facts are identical.
openai_image: {
decodeRequest: function (ctx) {
const model = trimmed(ctx.model);
if (!model) throw new Error("model is required");
let req = {};
const uploads = [];
if (ctx.body && ctx.body.kind === "json") {
req = ctx.body.value;
if (!req || typeof req !== "object" || Array.isArray(req)) throw new Error("request body must be an object");
} else if (ctx.body && ctx.body.kind === "multipart") {
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];
}
for (const key of ["n", "seed", "guidance_scale"]) {
if (req[key] !== undefined) req[key] = Number(req[key]);
}
for (const key of ["stream", "watermark", "optimize_prompt", "layer_decomposition"]) {
if (req[key] === undefined) continue;
if (req[key] !== "true" && req[key] !== "false") throw new Error(key + " must be true or false");
req[key] = req[key] === "true";
}
for (const key of ["optimize_prompt_options", "sequential_image_generation_options", "tools"]) {
if (req[key] === undefined) continue;
try {
req[key] = JSON.parse(req[key]);
} catch (e) {
throw new Error(key + " must be a JSON string");
}
}
for (const file of ctx.body.files || []) {
if (!/^image(\[\d*\])?$/.test(file.field)) throw new Error(file.field + " is not supported");
// Ark reads Base64 references only as data:image/<lowercase format>;base64 URLs.
const mimeType = trimmed(file.mimeType).toLowerCase();
uploads.push({
__fileRef: file.ref,
encoding: "dataUrl",
mimeType: /^image\/[a-z0-9.+-]+$/.test(mimeType) ? mimeType : "image/png",
maxBytes: MAX_INPUT_IMAGE_BYTES,
});
}
} else throw new Error("JSON or multipart body required");
if (req.stream !== undefined && req.stream !== false)
throw new Error("stream is not supported; the complete image response is returned once all images are generated");
if (req.response_format !== undefined && req.response_format !== "url" && req.response_format !== "b64_json")
throw new Error("response_format must be url or b64_json");
// Seedream has no per-request image count; multiple images come from group generation.
if (req.n !== undefined && req.n !== null && req.n !== 1) throw new Error("n must be 1; use sequential_image_generation for multiple images");
if (req.mask !== undefined) throw new Error("mask is not supported");
const requestBody = { model: model };
for (const key of IMAGE_REQUEST_KEYS.concat(["prompt", "image", "background", "tools"])) {
// The host inlines b64_json from the upstream URLs; Ark always answers with URLs.
if (key !== "response_format" && Object.prototype.hasOwnProperty.call(req, key)) requestBody[key] = req[key];
}
if (uploads.length) requestBody.image = [].concat(requestBody.image === undefined ? [] : requestBody.image, uploads);
const converted = convertImage({ model: model, requestBody: requestBody });
if (ctx.operation === "edit" && converted.action !== "image_to_image") throw new Error("image is required");
return { kind: "submit", model: model, action: converted.action, requestBody: requestBody };
},
render: function (ctx, task) {
return {
created: task.created_at,
data: imageURLEntries(artifactData(task)).map(function (item) {
return { url: trimmed(item.url) };
}),
};
},
},
};
const legacyRenderers = {
openai_video: function (task) {
const data = task.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: task.properties ? task.properties.origin_model_name || "" : "",
status: statusMap[task.status] || "unknown",
progress: Number(String(task.progress || "0").replace("%", "")),
created_at: task.created_at,
completed_at: task.updated_at,
};
if (data.status === "failed") output.error = { message: data.error ? data.error.message || "" : "", code: data.error ? data.error.code || "" : "" };
return output;
},
};
protocols.openai_video = {
decodeRequest: function (ctx) {
if (!ctx.body || (ctx.body.kind !== "json" && ctx.body.kind !== "multipart")) throw new Error("JSON or multipart body required");
if (ctx.body.kind === "json") {
if (!ctx.body.value || Array.isArray(ctx.body.value)) throw new Error("JSON object required");
const req = ctx.body.value;
const seconds = req.seconds === undefined ? req.duration : req.seconds;
if (seconds !== undefined && Number(seconds) !== -1 && (!Number.isFinite(Number(seconds)) || Number(seconds) <= 0 || Number(seconds) > 3600))
throw new Error("seconds must be -1 or between 1 and 3600");
const requestBody = Object.assign({}, req, { model: ctx.model });
// buildSubmitRequest reads seconds; duration is accepted as its alias.
if (seconds !== undefined) requestBody.seconds = seconds;
return {
kind: "submit",
model: ctx.model,
action: req.input_reference || req.image ? "image_to_video" : "text_to_video",
requestBody: requestBody,
};
}
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];
};
const req = {};
const fields = ctx.body.fields || {};
for (const name of Object.keys(fields)) {
req[name] = first(name);
}
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 ((ctx.body.files || []).length) throw new Error("Doubao requires image and video references to be URLs inside metadata.content");
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 && Number(seconds) !== -1 && (!Number.isFinite(Number(seconds)) || Number(seconds) <= 0 || Number(seconds) > 3600))
throw new Error("seconds must be -1 or between 1 and 3600");
return {
kind: "submit",
model: ctx.model,
action: req.input_reference || req.image ? "image_to_video" : "text_to_video",
requestBody: Object.assign({}, req, { model: ctx.model }),
};
},
render: function (ctx, task) {
return legacyRenderers.openai_video(task);
},
};