mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-05 18:21:16 +00:00
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.
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.upstreamModel || 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 };
|
|
},
|
|
};
|