mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-07 01:56:53 +00:00
337 lines
13 KiB
JavaScript
337 lines
13 KiB
JavaScript
// Unofficial plugin
|
|
// https://github.com/Suno-API/Suno-API
|
|
export const meta = {
|
|
apiVersion: 1,
|
|
key: "sunoapi",
|
|
name: "SunoAPI",
|
|
icon: "text",
|
|
description: {
|
|
en: "SunoAPI project music and lyrics generation",
|
|
zh: "SunoAPI 项目 音乐与歌词生成",
|
|
},
|
|
version: "1.0.0",
|
|
author: { name: "QuantumNous" },
|
|
channelTypes: [36],
|
|
models: ["suno_music", "suno_lyrics"],
|
|
fetchMode: "batch",
|
|
usageSchema: {
|
|
clips: {
|
|
type: "number",
|
|
unit: "count",
|
|
description: { en: "Number of generated music or lyrics clips.", zh: "生成的音乐或歌词片段数量。" },
|
|
},
|
|
action: { enum: ["music", "lyrics"], description: { en: "Suno generation action.", zh: "Suno 生成动作。" } },
|
|
},
|
|
protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }],
|
|
routes: [
|
|
{ method: "POST", path: "/suno/submit/:action", type: "submit", decode: "decodeSubmit", render: "renderSubmit" },
|
|
{ method: "POST", path: "/suno/fetch", type: "dynamic", decode: "decodeBatch", render: "renderTasks" },
|
|
{ method: "GET", path: "/suno/fetch/:task_id", type: "query", render: "renderTask" },
|
|
],
|
|
};
|
|
|
|
function trimmed(value) {
|
|
return String(value || "").trim();
|
|
}
|
|
|
|
function responsesText(req) {
|
|
const texts = [];
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
return texts
|
|
.filter(function (text) {
|
|
return trimmed(text);
|
|
})
|
|
.join("\n");
|
|
}
|
|
|
|
function actionName(ctx) {
|
|
return String((ctx.params || {}).action || ctx.action || "").toUpperCase();
|
|
}
|
|
|
|
function decodeNativeSubmit(ctx) {
|
|
if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
|
|
const action = actionName(ctx);
|
|
if (action !== "MUSIC" && action !== "LYRICS") throw new Error("invalid_action");
|
|
return {
|
|
kind: "submit",
|
|
model: action === "MUSIC" ? "suno_music" : "suno_lyrics",
|
|
action: action,
|
|
requestBody: ctx.body.value,
|
|
};
|
|
}
|
|
|
|
function decodeNativeBatch(ctx) {
|
|
if (!ctx.body || ctx.body.kind !== "json") throw new Error("JSON body required");
|
|
const body = ctx.body.value || {};
|
|
return { kind: "query", taskIds: Array.isArray(body.ids) ? body.ids : [] };
|
|
}
|
|
|
|
function validateAndNormalize(ctx) {
|
|
const body = Object.assign({}, ctx.requestBody || {});
|
|
if (body.make_instrumental === undefined) body.make_instrumental = false;
|
|
const action = actionName(ctx);
|
|
if (action === "MUSIC") {
|
|
if (!body.mv) body.mv = "chirp-v3-0";
|
|
} else if (action === "LYRICS") {
|
|
if (!body.prompt) throw new Error("prompt_empty");
|
|
} else {
|
|
throw new Error("invalid_action");
|
|
}
|
|
return { action: action, body: body };
|
|
}
|
|
|
|
export function buildSubmitRequest(ctx) {
|
|
const normalized = validateAndNormalize(ctx);
|
|
const incoming = ctx.requestHeaders || {};
|
|
return {
|
|
url: ctx.baseUrl + "/suno/submit/" + normalized.action,
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": incoming["Content-Type"] || "",
|
|
Accept: incoming.Accept || "",
|
|
Authorization: "Bearer " + ctx.apiKey,
|
|
},
|
|
body: normalized.body,
|
|
action: normalized.action,
|
|
};
|
|
}
|
|
|
|
export function parseSubmitResponse(ctx, resp) {
|
|
const body = resp.body || {};
|
|
if (body.code !== "success") throw new Error(String(body.message || ""));
|
|
if (!body.data) throw new Error("task_id is empty");
|
|
// The native create presenter runs after persistence, so retaining the
|
|
// acknowledgement message is required to preserve Suno's submit envelope.
|
|
// The first pre-poll status view therefore contains this temporary message;
|
|
// the first provider poll replaces task.data with the normal Suno payload.
|
|
return { taskId: body.data, taskData: { message: String(body.message || "") } };
|
|
}
|
|
|
|
export function extractUsage(ctx) {
|
|
if (ctx.usagePurpose === "billing_ratios") return null;
|
|
const model = trimmed(ctx.model || (ctx.requestBody || {}).model).toLowerCase();
|
|
const action = actionName(ctx).toLowerCase() || (model === "suno_lyrics" ? "lyrics" : "music");
|
|
return { clips: action === "lyrics" ? 1 : 2, action: action };
|
|
}
|
|
|
|
export function buildBatchQueryRequest(ctx, taskIds) {
|
|
return {
|
|
url: ctx.baseUrl + "/suno/fetch",
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Authorization: "Bearer " + ctx.apiKey },
|
|
body: { ids: taskIds },
|
|
};
|
|
}
|
|
|
|
// Required v1 per-task hooks remain defined for contract compatibility. Suno's
|
|
// host polling path uses the batch hooks below.
|
|
export function buildQueryRequest(ctx) {
|
|
return buildBatchQueryRequest(ctx, (ctx.requestBody || {}).ids || []);
|
|
}
|
|
|
|
export function parseBatchResult(ctx, body) {
|
|
if (body.code !== "success") throw new Error(String(body.message || ""));
|
|
return (body.data || []).map(function (item) {
|
|
return {
|
|
taskId: item.task_id || "",
|
|
action: item.action || "",
|
|
status: item.status || "",
|
|
reason: item.fail_reason || "",
|
|
submitTime: item.submit_time || 0,
|
|
startTime: item.start_time || 0,
|
|
finishTime: item.finish_time || 0,
|
|
data: item.data,
|
|
};
|
|
});
|
|
}
|
|
|
|
export function parseTaskResult(ctx, body) {
|
|
const results = parseBatchResult(ctx, { code: 200, data: [body] });
|
|
return results.length ? results[0] : { status: "UNKNOWN" };
|
|
}
|
|
|
|
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")) {
|
|
const nested = data.data.data;
|
|
return Array.isArray(nested) ? nested : nested && typeof nested === "object" ? [nested] : [];
|
|
}
|
|
return Array.isArray(data) ? data : data && typeof data === "object" ? [data] : [];
|
|
}
|
|
|
|
function artifactKey(type, song) {
|
|
return type + "-" + utils.hmacSHA256(String(song.id), "new-api:suno:artifact-key");
|
|
}
|
|
|
|
export function listArtifacts(task) {
|
|
if (task.status !== "SUCCESS") return [];
|
|
const artifacts = [];
|
|
for (const song of artifactData(task)) {
|
|
if (!song || !String(song.id || "").trim()) continue;
|
|
if (String(song.audio_url || "").trim()) {
|
|
artifacts.push({ key: artifactKey("audio", song), type: "audio", mimeType: "audio/mpeg" });
|
|
}
|
|
if (String(song.image_url || "").trim()) {
|
|
artifacts.push({ key: artifactKey("cover", song), type: "image" });
|
|
}
|
|
}
|
|
return artifacts;
|
|
}
|
|
|
|
export function buildContentRequest(ctx) {
|
|
const song = artifactData(ctx).find(function (item) {
|
|
if (!item || !String(item.id || "").trim()) return false;
|
|
return artifactKey("audio", item) === ctx.artifactKey || artifactKey("cover", item) === ctx.artifactKey;
|
|
});
|
|
if (!song) throw new Error("artifact_not_found");
|
|
let url = "";
|
|
if (artifactKey("audio", song) === ctx.artifactKey) url = String(song.audio_url || "").trim();
|
|
if (artifactKey("cover", song) === ctx.artifactKey) url = String(song.image_url || "").trim();
|
|
if (!url) throw new Error("artifact_not_found");
|
|
return { url: url, method: ctx.clientRequest.method, credentialless: true };
|
|
}
|
|
|
|
export function extractUsageOnComplete(task, taskResult, body) {
|
|
const values = Array.isArray(body) ? body : body && typeof body === "object" ? [body] : [];
|
|
if (values.length === 0) return {};
|
|
const music = values.some(function (item) {
|
|
return item && (trimmed(item.audio_url) || trimmed(item.video_url));
|
|
});
|
|
return { clips: values.length, action: music ? "music" : "lyrics" };
|
|
}
|
|
|
|
function escapedAttribute(value) {
|
|
return trimmed(value).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
}
|
|
|
|
function responseContent(ctx, task) {
|
|
const model = trimmed(ctx && ctx.requestBody && ctx.requestBody.model).toLowerCase();
|
|
const songs = artifactData(task);
|
|
const lyrics = [];
|
|
for (const song of songs) {
|
|
if (!song) continue;
|
|
const text = trimmed(song.text);
|
|
if (!text) continue;
|
|
const title = trimmed(song.title);
|
|
lyrics.push(title ? title + "\n" + text : text);
|
|
}
|
|
if (model === "suno_lyrics") {
|
|
return [{ type: "output_text", text: lyrics.join("\n\n") || "Lyrics generation completed.", annotations: [], logprobs: [] }];
|
|
}
|
|
const content = [{ type: "output_text", text: lyrics.join("\n\n") || "Music generation completed.", annotations: [], logprobs: [] }];
|
|
for (const song of songs) {
|
|
if (!song || !trimmed(song.audio_url)) continue;
|
|
const key = artifactKey("audio", song);
|
|
const artifact = ctx && ctx.artifacts && ctx.artifacts[key];
|
|
const url = trimmed(artifact && artifact.url);
|
|
if (!url) throw new Error("audio artifact is unavailable");
|
|
content.push({ type: "output_text", text: '<audio controls src="' + escapedAttribute(url) + '"></audio>', annotations: [], logprobs: [] });
|
|
}
|
|
return content;
|
|
}
|
|
|
|
function responseText(ctx, task) {
|
|
return responseContent(ctx, task)
|
|
.map(function (part) {
|
|
return part.text;
|
|
})
|
|
.join("\n\n");
|
|
}
|
|
|
|
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 !== "suno_music" && model !== "suno_lyrics") 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.metadata !== undefined && (!req.metadata || typeof req.metadata !== "object" || Array.isArray(req.metadata)))
|
|
throw new Error("metadata must be an object");
|
|
const input = responsesText(req);
|
|
const requestBody = Object.assign({}, req.metadata || {});
|
|
if (model === "suno_lyrics") {
|
|
if (!trimmed(requestBody.prompt)) requestBody.prompt = input || trimmed(req.prompt);
|
|
if (!trimmed(requestBody.prompt)) throw new Error("input is required");
|
|
return { kind: "submit", model: model, action: "LYRICS", requestBody: requestBody };
|
|
}
|
|
if (!trimmed(requestBody.gpt_description_prompt)) requestBody.gpt_description_prompt = input || trimmed(req.prompt);
|
|
if (!trimmed(requestBody.gpt_description_prompt) && !trimmed(requestBody.prompt)) throw new Error("input is required");
|
|
return { kind: "submit", model: model, action: "MUSIC", 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 = responseText(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: responseContent(ctx, task) }], metadata: { vendor: "sunoapi" } };
|
|
},
|
|
},
|
|
};
|
|
|
|
function nativeTask(task) {
|
|
return {
|
|
created_at: task.created_at || 0,
|
|
updated_at: task.updated_at || 0,
|
|
task_id: task.task_id || "",
|
|
platform: task.platform || "sunoapi",
|
|
status: task.status || "",
|
|
fail_reason: task.fail_reason || "",
|
|
submit_time: task.created_at || 0,
|
|
finish_time: task.finished_at || 0,
|
|
progress: task.progress || "",
|
|
data: task.data === undefined ? null : task.data,
|
|
};
|
|
}
|
|
|
|
export const native = {
|
|
decodeSubmit: decodeNativeSubmit,
|
|
decodeBatch: decodeNativeBatch,
|
|
renderSubmit: function (ctx, task) {
|
|
const data = task.data && typeof task.data === "object" ? task.data : {};
|
|
return { code: "success", message: String(data.message || ""), data: String(task.task_id || "") };
|
|
},
|
|
renderTask: function (ctx, task) {
|
|
return { code: "success", message: "", data: nativeTask(task) };
|
|
},
|
|
renderTasks: function (ctx, tasks) {
|
|
return { code: "success", message: "", data: tasks.map(nativeTask) };
|
|
},
|
|
error: function (ctx, error) {
|
|
return { code: error.code, message: error.message, data: null };
|
|
},
|
|
};
|