Files
new-api/plugins/tasks/google/plugin.js
T
CaIon 9df450fe54 feat(task): give polling hooks a real query context, host HTTP classification, and bounded poll failures
Plugin polling hooks previously ran against a hollow context: parseTaskResult
and parseBatchResult received {} / nil, buildQueryRequest received a
{task_id, action} map under the misleading name requestBody, and batch hooks
saw only bare task ids. The per-task poller also never looked at the upstream
HTTP status, and every built-in plugin papered over unrecognized bodies with
`|| "IN_PROGRESS"`, so a 404, a revoked key, or a shape the plugin did not
know would sit in IN_PROGRESS for the full 24h TASK_TIMEOUT_MINUTES while
holding the user's pre-charged quota.

Contract (docs/plugin-api v1.d.ts, v1.md, v1.schema.json):
- TaskQueryContext is declared separately from DriverContext and rebuilt from
  the persisted Task row: taskId, publicTaskId, action, model, upstreamModel,
  baseUrl, apiKey, authHeader, auth, data, state. Query-side requestBody is
  removed; the original request is not persisted and hooks that need a
  request-derived value must save it into state at submit time.
- parseTaskResult / parseBatchResult receive a third {status, headers}
  argument. Batch hooks receive tasks[] with one TaskQueryContext per task.
- NormalizedTaskResult accepts status "UNKNOWN" meaning "I do not recognize
  this body". Falling back to IN_PROGRESS for unknown shapes is forbidden;
  `plugin lint` warns on the literal.
- parseSubmitResponse / parseTaskResult / parseBatchResult may return `state`.
  Task.Data remains a per-round snapshot overwritten on every valid parse;
  state is plugin-owned, persisted in TaskPrivateData.PluginState, preserved
  when a hook omits it, byte-capped like taskData, and never exposed through
  presenter views.

Host (service/task_polling.go, relay/channel/task/jsplugin/adaptor.go):
- TaskPollingAdaptor / BatchTaskPollingAdaptor take *model.Task and the
  *http.Response so the adaptor can build the full context; jsplugin is the
  only implementation.
- HTTP classification before the plugin sees the body: 2xx -> plugin;
  404/410 -> FAILURE and refund; 401/403 -> poll failure plus a channel-scoped
  warning, no auto-disable; 429/5xx/transport -> poll failure; other 4xx ->
  plugin with the status visible, counted as unrecognized if the plugin still
  reports a non-terminal state.
- TaskPrivateData.PollFailures counts consecutive poll failures (transient
  HTTP, auth, transport, hook error, UNKNOWN). It is persisted through the
  existing UpdateWithStatus CAS so a concurrent terminal transition on another
  instance is never clobbered, and reset on any valid 2xx non-terminal parse.
  Reaching TASK_POLL_MAX_FAILURES (default 20, <= 0 disables) fails the task
  with the last classification and HTTP code in fail_reason and runs the
  existing settle/refund chain exactly once. sweepTimedOutTasks and its
  1440-minute default are unchanged as the outer backstop.
- Unrecognized bodies are logged at WARN with a bounded redacted copy since
  Task.Data is intentionally not overwritten on that path.

Plugins (all ten bumped one patch version):
- jimeng persists the outbound req_key in state and reads it back in
  buildQueryRequest, replacing dead reads of ctx.data / ctx.requestBody that
  never resolved.
- sunoapi batch hooks read tasks[] instead of the removed requestBody.
- hailuo treats base_resp.status_code != 0 as FAILURE before the status table.
- kling, vidu, sora, alibaba, doubao, hailuo, jimeng return UNKNOWN with the
  raw upstream status in reason on table miss.
- google and vertex-ai treat a missing `done` as in-progress: Google
  long-running operations omit proto3 default fields, so a running Veo
  operation has no `done` key at all. Only a body without an operation name is
  UNKNOWN. plugins/veo_poll_test.go locks this so the poll-failure cutoff can
  never fail a rendering Veo task.

Tests cover the classification table end to end against a real DB (404
immediate refund, 429xN refund, 401 increments without status change, 2xx
reset, UNKNOWN increments, state preserved vs replaced, PollFailures survives
the CAS write), the query-context shape, UNKNOWN on unrecognized bodies, and
the absence of PluginState/PollFailures from TaskView. Controller tests derive
the kling factory version from the embedded manifest instead of hardcoding it.
2026-09-03 14:37:47 +08:00

386 lines
16 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: "google",
name: "Google Veo (Gemini API)",
icon: "Gemini.Color",
description: {
en: "Google Veo video generation on the Gemini API (text-to-video and image-to-video)",
zh: "Google Veo 视频生成(文生视频、图生视频),Gemini API 版本",
},
version: "1.0.1",
author: { name: "QuantumNous" },
channelTypes: [24],
models: ["veo-3.0-generate-001", "veo-3.0-fast-generate-001", "veo-3.1-generate-preview", "veo-3.1-fast-generate-preview"],
fetchMode: "per_task",
usageSchema: {
seconds: {
type: "number",
unit: "second",
description: {
en: "Requested video duration in seconds. Allowed values: 4, 6, 8.",
zh: "请求的视频时长,单位为秒。允许值为 4、6、8。",
},
},
resolution: {
enum: ["720p", "1080p", "4k"],
description: {
en: "Requested video output resolution. Veo prices differ per resolution tier.",
zh: "请求的输出视频分辨率。Veo 各分辨率档位计费不同。",
},
},
},
usageExamples: [
{ label: "8s × 720p", facts: { seconds: 8, resolution: "720p" } },
{ label: "8s × 1080p", facts: { seconds: 8, resolution: "1080p" } },
{ label: "8s × 4k", facts: { seconds: 8, resolution: "4k" } },
{ label: "4s × 720p", facts: { seconds: 4, resolution: "720p" } },
],
protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
};
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 sizeParts(size) {
const parts = String(size || "")
.toLowerCase()
.split("x");
if (parts.length !== 2) return null;
return [Number(parts[0]), Number(parts[1])];
}
function resolutionForSize(size) {
const parts = sizeParts(size);
if (!parts) return "720p";
const max = Math.max(parts[0], parts[1]);
if (max >= 3840) return "4k";
if (max >= 1920) return "1080p";
return "720p";
}
function aspectForSize(size) {
const parts = sizeParts(size);
if (!parts || parts[0] <= 0 || parts[1] <= 0) return "16:9";
return parts[1] > parts[0] ? "9:16" : "16:9";
}
function imageInput(value, files) {
if (value && typeof value === "object" && !Array.isArray(value) && value.__fileRef) {
let mime = value.mimeType || "";
if (!mime && files) {
for (const file of files) {
if (file.ref === value.__fileRef || file.field === "input_reference") {
mime = file.mimeType || "";
break;
}
}
}
return { inlineData: { mimeType: mime || "application/octet-stream", data: value } };
}
value = String(value || "").trim();
if (!value) return null;
if (value.startsWith("data:")) {
const comma = value.indexOf(",");
if (comma < 0 || !value.slice(comma + 1)) return null;
const mediaType = value.slice(5, comma).split(";")[0];
return { inlineData: { mimeType: mediaType || "application/octet-stream", data: value.slice(comma + 1) } };
}
// Raw base64 input is accepted by the Go adaptor. The common fixtures use
// PNG data; browser-free plugins cannot invoke net/http DetectContentType.
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
let mime = "application/octet-stream";
if (value.startsWith("iVBORw0KGgo")) mime = "image/png";
else if (value.startsWith("/9j/")) mime = "image/jpeg";
else if (value.startsWith("R0lGOD")) mime = "image/gif";
else if (value.startsWith("UklGR")) mime = "image/webp";
return { inlineData: { mimeType: mime, data: value } };
}
function converted(ctx) {
const req = ctx.requestBody || {};
const metadata = Object.assign({}, req.metadata || {});
const params = Object.assign({}, metadata);
if (Number(req.duration) > 0) params.durationSeconds = Number(req.duration);
if (!params.resolution && req.size) params.resolution = resolutionForSize(req.size);
if (!params.aspectRatio && req.size) params.aspectRatio = aspectForSize(req.size);
if (params.resolution) params.resolution = String(params.resolution).toLowerCase();
params.numberOfVideos = 1;
const instance = { prompt: req.prompt };
const image = imageInput((req.images || [])[0], ctx.files);
if (image) instance.image = image;
return { body: { instances: [instance], parameters: params }, action: image ? "image_to_video" : "text_to_video" };
}
function version(ctx) {
const settings = ctx.userSetting || {};
return settings.geminiVersion || "v1beta";
}
export function buildSubmitRequest(ctx) {
const result = converted(ctx);
return {
url: ctx.baseUrl + "/" + version(ctx) + "/models/" + ctx.upstreamModel + ":predictLongRunning",
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json", "x-goog-api-key": ctx.apiKey },
body: result.body,
action: result.action,
};
}
export function parseSubmitResponse(ctx, resp) {
const body = resp.body || {};
if (!String(body.name || "").trim()) throw new Error("missing operation name");
const result = { taskId: utils.base64URL(body.name), taskData: body };
if (body.done && !(body.error && body.error.message)) {
const videos = ((body.response || {}).generateVideoResponse || {}).generatedVideos || [];
const uri = videos.length && videos[0].video ? videos[0].video.uri || "" : "";
result.immediate = { taskId: result.taskId, status: "SUCCESS", progress: "100%", remoteUrl: uri };
}
return result;
}
export function extractUsage(ctx) {
const req = ctx.requestBody || {};
const metadata = req.metadata || {};
let seconds = Number(req.duration);
if (!Number.isFinite(seconds) || seconds <= 0) seconds = 8;
const resolution = String(metadata.resolution || resolutionForSize(req.size) || "720p").toLowerCase();
return { seconds: seconds, resolution: resolution };
}
export function extractUsageOnComplete() {
return null;
}
export function buildQueryRequest(ctx) {
return {
url: ctx.baseUrl + "/v1beta/" + utils.base64URLDecode(ctx.taskId),
method: "GET",
headers: { Accept: "application/json", "x-goog-api-key": ctx.apiKey },
};
}
export function parseTaskResult(ctx, body) {
if (body.error && body.error.message) return { status: "FAILURE", progress: "100%", reason: body.error.message };
// Google long-running operations omit `done` (proto3 default) while still
// running, so a missing key means in-progress; only a non-operation shape is
// unrecognized.
if (!body || typeof body !== "object" || !String(body.name || "").trim()) return { status: "UNKNOWN", reason: "unrecognized operation state" };
if (body.done !== true) return { status: "IN_PROGRESS", progress: "50%" };
const videos = ((body.response || {}).generateVideoResponse || {}).generatedVideos || [];
const uri = videos.length && videos[0].video ? videos[0].video.uri || "" : "";
return { taskId: utils.base64URL(body.name || ""), status: "SUCCESS", progress: "100%", remoteUrl: uri };
}
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 videos = ((artifactData(ctx).response || {}).generateVideoResponse || {}).generatedVideos || [];
return videos.length && videos[0].video ? String(videos[0].video.uri || "").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, headers: { "x-goog-api-key": ctx.apiKey } };
}
export const protocols = {
openai_responses: {
decodeRequest: function (ctx) {
if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
const req = ctx.body.value;
if (!req || typeof req !== "object" || Array.isArray(req)) throw new Error("request body must be an object");
const model = trimmed(req.model);
if (!model) throw new Error("model is required");
if (req.input !== undefined && typeof req.input !== "string" && !Array.isArray(req.input)) throw new Error("input must be a string or array");
if (req.images !== undefined && !Array.isArray(req.images)) throw new Error("images must be an array");
if (req.metadata !== undefined && (!req.metadata || typeof req.metadata !== "object" || Array.isArray(req.metadata)))
throw new Error("metadata must be an object");
const input = responsesInput(req);
const 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");
if (images.length && !imageInput(images[0])) throw new Error("input image must be a data URL or base64 value");
const metadata = Object.assign({}, req.metadata || {});
if (Object.prototype.hasOwnProperty.call(req, "resolution")) metadata.resolution = req.resolution;
const requestBody = { model: model, prompt: prompt, metadata: metadata };
if (images.length) requestBody.images = images;
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: "gemini" },
};
},
},
};
const legacyRenderers = {
openai_video: function (task) {
const model = (task.properties || {}).origin_model_name || "veo-3.0-generate-001";
const statuses = { SUBMITTED: "queued", QUEUED: "queued", IN_PROGRESS: "in_progress", SUCCESS: "completed", FAILURE: "failed" };
const output = {
id: task.task_id,
object: "video",
model: model,
status: statuses[task.status] || "unknown",
progress: Number(String(task.progress || "0").replace("%", "")),
created_at: task.created_at,
};
if (Number(task.finish_time) > 0) output.completed_at = Number(task.finish_time);
else if (Number(task.updated_at) > 0) output.completed_at = Number(task.updated_at);
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");
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 {
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) {
const n = Number(seconds);
if (n !== 4 && n !== 6 && n !== 8) throw new Error("seconds must be one of 4, 6, or 8");
req.duration = n;
}
const providedResolution = req.resolution !== undefined ? req.resolution : req.metadata && req.metadata.resolution;
if (providedResolution !== undefined && providedResolution !== "") {
const resolution = String(providedResolution).toLowerCase();
if (resolution !== "720p" && resolution !== "1080p" && resolution !== "4k") throw new Error("resolution must be one of 720p, 1080p, or 4k");
}
if (hasInputReferenceFile) {
req.images = [{ __fileRef: "request_file:input_reference", encoding: "base64", maxBytes: 20971520 }];
}
return {
kind: "submit",
model: ctx.model,
action: hasInputReferenceFile || 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);
},
};