mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Error message sanitization (Hard Rule #12): - claude-auth/export, codex-auth/export, gemini-cli-auth/export routes: replace raw err.message with sanitizeErrorMessage() from open-sse/utils/error.ts - imageGeneration, musicGeneration, videoGeneration handlers: import sanitizeErrorMessage and replace all err.message in return values - veoaifree-web executor: replace raw upstream response data in errResp() calls with static strings OAuth callback page (callback/page.tsx): - Remove useSearchParams/Suspense dependency that caused hydration failures in popup windows navigating back from Google OAuth (COOP header severs opener) - Use window.location.search directly in useEffect with three send methods: postMessage, BroadcastChannel, localStorage - Fix postMessage target from "*" to window.location.origin (semgrep finding) - Move setCurrentUrl call to manual-only branch to avoid unnecessary renders copilot-web executor: - Move accessToken from WebSocket URL query string to Authorization header (avoids credential exposure in server logs) - Add MAX_POOL_SIZE=100 cap to sessionPool with LRU eviction of oldest entry CodeQL ReDoS fixes (js/polynomial-redos #233-240): - Replace while(s.endsWith("/")) s=s.slice(0,-1) pattern (O(n²) allocations) with index-based loop (O(n) time, single final slice) in: bin/cli/api.mjs, all 6 cli-helper config generators, opencode-provider Gemini OAuth: - mapTokens: add idToken field to fix "missing id_token" export error
617 lines
18 KiB
TypeScript
617 lines
18 KiB
TypeScript
/**
|
|
* Music Generation Handler
|
|
*
|
|
* Handles POST /v1/music/generations requests.
|
|
* Proxies to upstream music generation providers.
|
|
*
|
|
* Supported provider formats:
|
|
* - ComfyUI: submit audio workflow → poll → fetch output
|
|
*
|
|
* Response format (OpenAI-like):
|
|
* {
|
|
* "created": 1234567890,
|
|
* "data": [{ "b64_json": "...", "format": "wav" }]
|
|
* }
|
|
*/
|
|
|
|
import { getMusicProvider, parseMusicModel } from "../config/musicRegistry.ts";
|
|
import { kieExecutor } from "../executors/kie.ts";
|
|
import {
|
|
submitComfyWorkflow,
|
|
pollComfyResult,
|
|
fetchComfyOutput,
|
|
extractComfyOutputFiles,
|
|
} from "../utils/comfyuiClient.ts";
|
|
import { saveCallLog } from "@/lib/usageDb";
|
|
import { getKieCallbackUrl, isJsonObject, parseKieResultJson } from "../utils/kieTask.ts";
|
|
import { sanitizeErrorMessage } from "../utils/error.ts";
|
|
|
|
function normalizeKieSunoModel(model: string): string {
|
|
const map: Record<string, string> = {
|
|
"suno-v3.5": "V3_5",
|
|
"suno-v4.0": "V4",
|
|
};
|
|
return map[model] || model;
|
|
}
|
|
|
|
function normalizeKieMusicTracks(recordData: unknown): Array<Record<string, unknown>> {
|
|
const record = isJsonObject(recordData) ? recordData : {};
|
|
const data = isJsonObject(record.data) ? record.data : {};
|
|
const response = isJsonObject(data.response) ? data.response : {};
|
|
const resultJson = parseKieResultJson(recordData);
|
|
const candidates = [
|
|
response.sunoData,
|
|
response.data,
|
|
data.data,
|
|
data.sunoData,
|
|
resultJson.sunoData,
|
|
resultJson.data,
|
|
resultJson.result,
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
if (Array.isArray(candidate) && candidate.length > 0) {
|
|
return candidate
|
|
.map((track) =>
|
|
isJsonObject(track) ? track : typeof track === "string" ? { audioUrl: track } : null
|
|
)
|
|
.filter((track): track is Record<string, unknown> => track !== null);
|
|
}
|
|
}
|
|
|
|
const singleUrl =
|
|
response.audioUrl ||
|
|
response.audio_url ||
|
|
data.resultUrl ||
|
|
data.audio_url ||
|
|
resultJson.audioUrl ||
|
|
resultJson.audio_url ||
|
|
resultJson.url;
|
|
|
|
return typeof singleUrl === "string" && singleUrl.length > 0 ? [{ audioUrl: singleUrl }] : [];
|
|
}
|
|
|
|
/**
|
|
* Handle music generation request
|
|
*/
|
|
export async function handleMusicGeneration({ body, credentials, log }) {
|
|
const { provider, model } = parseMusicModel(body.model);
|
|
|
|
if (!provider) {
|
|
return {
|
|
success: false,
|
|
status: 400,
|
|
error: `Invalid music model: ${body.model}. Use format: provider/model`,
|
|
};
|
|
}
|
|
|
|
const providerConfig = getMusicProvider(provider);
|
|
if (!providerConfig) {
|
|
return {
|
|
success: false,
|
|
status: 400,
|
|
error: `Unknown music provider: ${provider}`,
|
|
};
|
|
}
|
|
|
|
if (providerConfig.format === "comfyui") {
|
|
return handleComfyUIMusicGeneration({ model, provider, providerConfig, body, log });
|
|
}
|
|
|
|
if (providerConfig.format === "kie-music") {
|
|
return handleKieMusicGeneration({ model, provider, providerConfig, body, credentials, log });
|
|
}
|
|
|
|
if (providerConfig.format === "suno-music") {
|
|
return handleSunoMusicGeneration({ model, provider, providerConfig, body, credentials, log });
|
|
}
|
|
if (providerConfig.format === "udio-music") {
|
|
return handleUdioMusicGeneration({ model, provider, providerConfig, body, credentials, log });
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
status: 400,
|
|
error: `Unsupported music format: ${providerConfig.format}`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Handle ComfyUI music generation
|
|
* Submits an audio generation workflow (Stable Audio / MusicGen), polls, fetches output
|
|
*/
|
|
async function handleComfyUIMusicGeneration({ model, provider, providerConfig, body, log }) {
|
|
const startTime = Date.now();
|
|
const duration = body.duration || 10; // seconds
|
|
|
|
// Audio generation workflow template for ComfyUI
|
|
const workflow = {
|
|
"1": {
|
|
class_type: "CheckpointLoaderSimple",
|
|
inputs: { ckpt_name: model },
|
|
},
|
|
"2": {
|
|
class_type: "CLIPTextEncode",
|
|
inputs: { text: body.prompt, clip: ["1", 1] },
|
|
},
|
|
"3": {
|
|
class_type: "CLIPTextEncode",
|
|
inputs: { text: body.negative_prompt || "", clip: ["1", 1] },
|
|
},
|
|
"4": {
|
|
class_type: "EmptyLatentAudio",
|
|
inputs: { seconds: duration },
|
|
},
|
|
"5": {
|
|
class_type: "KSampler",
|
|
inputs: {
|
|
seed: Math.floor(Math.random() * 2 ** 32),
|
|
steps: body.steps || 100,
|
|
cfg: body.cfg_scale || 7,
|
|
sampler_name: "euler",
|
|
scheduler: "normal",
|
|
denoise: 1,
|
|
model: ["1", 0],
|
|
positive: ["2", 0],
|
|
negative: ["3", 0],
|
|
latent_image: ["4", 0],
|
|
},
|
|
},
|
|
"6": {
|
|
class_type: "VAEDecodeAudio",
|
|
inputs: { samples: ["5", 0], vae: ["1", 2] },
|
|
},
|
|
"7": {
|
|
class_type: "SaveAudio",
|
|
inputs: {
|
|
filename_prefix: "omniroute_music",
|
|
audio: ["6", 0],
|
|
},
|
|
},
|
|
};
|
|
|
|
if (log) {
|
|
const promptPreview = String(body.prompt ?? "").slice(0, 60);
|
|
log.info(
|
|
"MUSIC",
|
|
`${provider}/${model} (comfyui) | prompt: "${promptPreview}..." | duration: ${duration}s`
|
|
);
|
|
}
|
|
|
|
try {
|
|
const promptId = await submitComfyWorkflow(providerConfig.baseUrl, workflow);
|
|
const historyEntry = await pollComfyResult(providerConfig.baseUrl, promptId, 300_000);
|
|
const outputFiles = extractComfyOutputFiles(historyEntry);
|
|
|
|
const audioFiles = [];
|
|
for (const file of outputFiles) {
|
|
const buffer = await fetchComfyOutput(
|
|
providerConfig.baseUrl,
|
|
file.filename,
|
|
file.subfolder,
|
|
file.type
|
|
);
|
|
const base64 = Buffer.from(buffer).toString("base64");
|
|
audioFiles.push({ b64_json: base64, format: "wav" });
|
|
}
|
|
|
|
saveCallLog({
|
|
method: "POST",
|
|
path: "/v1/music/generations",
|
|
status: 200,
|
|
model: `${provider}/${model}`,
|
|
provider,
|
|
duration: Date.now() - startTime,
|
|
responseBody: { audio_count: audioFiles.length },
|
|
}).catch(() => {});
|
|
|
|
return {
|
|
success: true,
|
|
data: { created: Math.floor(Date.now() / 1000), data: audioFiles },
|
|
};
|
|
} catch (err) {
|
|
if (log) log.error("MUSIC", `${provider} comfyui error: ${err.message}`);
|
|
saveCallLog({
|
|
method: "POST",
|
|
path: "/v1/music/generations",
|
|
status: 502,
|
|
model: `${provider}/${model}`,
|
|
provider,
|
|
duration: Date.now() - startTime,
|
|
error: err.message,
|
|
}).catch(() => {});
|
|
return {
|
|
success: false,
|
|
status: 502,
|
|
error: sanitizeErrorMessage(err) || "Music provider error",
|
|
};
|
|
}
|
|
}
|
|
|
|
async function handleKieMusicGeneration({
|
|
model,
|
|
provider,
|
|
providerConfig,
|
|
body,
|
|
credentials,
|
|
log,
|
|
}: {
|
|
model: string;
|
|
provider: string;
|
|
providerConfig: {
|
|
baseUrl: string;
|
|
statusUrl?: string;
|
|
};
|
|
body: Record<string, unknown> & {
|
|
prompt?: unknown;
|
|
timeout_ms?: unknown;
|
|
poll_interval_ms?: unknown;
|
|
};
|
|
credentials?: {
|
|
apiKey?: string;
|
|
accessToken?: string;
|
|
} | null;
|
|
log?: {
|
|
info: (scope: string, message: string) => void;
|
|
error: (scope: string, message: string) => void;
|
|
} | null;
|
|
}) {
|
|
const startTime = Date.now();
|
|
const timeoutMs = Number(body.timeout_ms) > 0 ? Number(body.timeout_ms) : 300000;
|
|
const pollIntervalMs = Number(body.poll_interval_ms) > 0 ? Number(body.poll_interval_ms) : 2500;
|
|
const token = credentials?.apiKey || credentials?.accessToken;
|
|
const baseUrl = providerConfig.baseUrl.replace(/\/$/, "");
|
|
const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? "");
|
|
|
|
if (!token) {
|
|
return { success: false, status: 401, error: "KIE API key is required" };
|
|
}
|
|
|
|
// Check if model is a Market model
|
|
const fullRegistry = getMusicProvider(provider);
|
|
const modelEntry = fullRegistry?.models?.find((m) => m.id === model);
|
|
const isMarket = modelEntry?.isMarket || model.includes("/");
|
|
|
|
let url = "";
|
|
let payload: Record<string, unknown> = {};
|
|
|
|
if (isMarket) {
|
|
url = `${baseUrl}/api/v1/jobs/createTask`;
|
|
payload = {
|
|
model,
|
|
callBackUrl: getKieCallbackUrl(body),
|
|
input: {
|
|
prompt,
|
|
instrumental: true,
|
|
},
|
|
};
|
|
} else {
|
|
url = `${baseUrl}/api/v1/generate`;
|
|
payload = {
|
|
prompt,
|
|
customMode: false,
|
|
instrumental: true,
|
|
model: normalizeKieSunoModel(model),
|
|
callBackUrl: getKieCallbackUrl(body),
|
|
};
|
|
}
|
|
|
|
if (log) {
|
|
const promptPreview = String(body.prompt ?? "").slice(0, 60);
|
|
log.info(
|
|
"MUSIC",
|
|
`${provider}/${model} (${isMarket ? "market" : "direct"}) | prompt: "${promptPreview}..."`
|
|
);
|
|
}
|
|
|
|
try {
|
|
const endpoint = new URL(url).pathname;
|
|
const createData = await kieExecutor.createTask({ baseUrl, token, payload, endpoint });
|
|
const taskId = createData?.data?.taskId || createData?.taskId;
|
|
if (!taskId) {
|
|
const errorMessage =
|
|
createData?.msg ||
|
|
createData?.message ||
|
|
createData?.error ||
|
|
"KIE music generation did not return taskId";
|
|
if (log) {
|
|
log.error("MUSIC", `KIE createTask failed: ${JSON.stringify(createData)}`);
|
|
}
|
|
return { success: false, status: 502, error: errorMessage };
|
|
}
|
|
|
|
const statusUrl = isMarket
|
|
? `${baseUrl}/api/v1/jobs/recordInfo`
|
|
: providerConfig.statusUrl && !providerConfig.statusUrl.includes("jobs/recordInfo")
|
|
? providerConfig.statusUrl
|
|
: `${baseUrl}/api/v1/generate/record-info`;
|
|
|
|
const { data: recordData, state } = await kieExecutor.pollTask({
|
|
statusUrl,
|
|
taskId: String(taskId),
|
|
token,
|
|
timeoutMs,
|
|
pollIntervalMs,
|
|
});
|
|
|
|
if (state === "success") {
|
|
const tracks = normalizeKieMusicTracks(recordData);
|
|
|
|
const audioFiles = tracks
|
|
.map((track) =>
|
|
typeof track.audioUrl === "string"
|
|
? track.audioUrl
|
|
: typeof track.audio_url === "string"
|
|
? track.audio_url
|
|
: typeof track.url === "string"
|
|
? track.url
|
|
: null
|
|
)
|
|
.filter((url): url is string => typeof url === "string" && url.length > 0)
|
|
.map((url: string) => ({ url, format: "mp3" }));
|
|
|
|
saveCallLog({
|
|
method: "POST",
|
|
path: "/v1/music/generations",
|
|
status: 200,
|
|
model: `${provider}/${model}`,
|
|
provider,
|
|
duration: Date.now() - startTime,
|
|
responseBody: { audio_count: audioFiles.length },
|
|
}).catch(() => {});
|
|
|
|
return {
|
|
success: true,
|
|
data: { created: Math.floor(Date.now() / 1000), data: audioFiles },
|
|
};
|
|
}
|
|
|
|
const record = isJsonObject(recordData) ? recordData : {};
|
|
const data = isJsonObject(record.data) ? record.data : {};
|
|
const errorMessage = data.errorMessage || data.failMsg || record.msg || "KIE music task failed";
|
|
return { success: false, status: 502, error: String(errorMessage) };
|
|
} catch (err: unknown) {
|
|
return {
|
|
success: false,
|
|
status: isJsonObject(err) && Number.isFinite(Number(err.status)) ? Number(err.status) : 502,
|
|
error: sanitizeErrorMessage(err) || "Music provider error",
|
|
};
|
|
}
|
|
}
|
|
|
|
async function handleSunoMusicGeneration({
|
|
model,
|
|
provider,
|
|
providerConfig,
|
|
body,
|
|
credentials,
|
|
log,
|
|
}) {
|
|
const startTime = Date.now();
|
|
const cookie = credentials?.apiKey || credentials?.providerSpecificData?.cookie || "";
|
|
if (!cookie) {
|
|
return { success: false, status: 401, error: "Suno session cookie is required" };
|
|
}
|
|
const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? "");
|
|
if (log) {
|
|
log.info("MUSIC", `${provider}/${model} (suno) | prompt: "${prompt.slice(0, 60)}..."`);
|
|
}
|
|
try {
|
|
const res = await fetch(providerConfig.baseUrl, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Cookie: cookie },
|
|
body: JSON.stringify({
|
|
gpt_description_prompt: prompt,
|
|
mv: model || "chirp-v3-5",
|
|
prompt: body.lyrics || "",
|
|
title: body.title || "",
|
|
tags: body.tags || "",
|
|
make_instrumental: body.instrumental || false,
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
const errorText = await res.text();
|
|
saveCallLog({
|
|
method: "POST",
|
|
path: "/v1/music/generations",
|
|
status: res.status,
|
|
model: `${provider}/${model}`,
|
|
provider,
|
|
duration: Date.now() - startTime,
|
|
error: errorText.slice(0, 500),
|
|
}).catch(() => {});
|
|
return { success: false, status: res.status, error: errorText };
|
|
}
|
|
const clips = await res.json();
|
|
const ids = clips.map((c) => c.id).filter(Boolean);
|
|
if (ids.length === 0) {
|
|
saveCallLog({
|
|
method: "POST",
|
|
path: "/v1/music/generations",
|
|
status: 502,
|
|
model: `${provider}/${model}`,
|
|
provider,
|
|
duration: Date.now() - startTime,
|
|
error: "No clips returned from Suno",
|
|
}).catch(() => {});
|
|
return { success: false, status: 502, error: "No clips returned from Suno" };
|
|
}
|
|
const deadline = Date.now() + 300000;
|
|
while (Date.now() < deadline) {
|
|
await new Promise((r) => setTimeout(r, 5000));
|
|
const feedRes = await fetch(`${providerConfig.statusUrl}?ids=${ids.join(",")}`, {
|
|
headers: { Cookie: cookie },
|
|
});
|
|
const songs = await feedRes.json();
|
|
const ready = songs.filter((s) => s.audio_url);
|
|
if (ready.length > 0) {
|
|
const audioRes = await fetch(ready[0].audio_url);
|
|
if (!audioRes.ok) {
|
|
return {
|
|
success: false,
|
|
status: audioRes.status,
|
|
error: `Failed to download audio: ${audioRes.status}`,
|
|
};
|
|
}
|
|
const buf = await audioRes.arrayBuffer();
|
|
saveCallLog({
|
|
method: "POST",
|
|
path: "/v1/music/generations",
|
|
status: 200,
|
|
model: `${provider}/${model}`,
|
|
provider,
|
|
duration: Date.now() - startTime,
|
|
}).catch(() => {});
|
|
return {
|
|
success: true,
|
|
data: {
|
|
created: Math.floor(Date.now() / 1000),
|
|
data: [{ b64_json: Buffer.from(buf).toString("base64"), format: "mp3" }],
|
|
},
|
|
};
|
|
}
|
|
}
|
|
saveCallLog({
|
|
method: "POST",
|
|
path: "/v1/music/generations",
|
|
status: 504,
|
|
model: `${provider}/${model}`,
|
|
provider,
|
|
duration: Date.now() - startTime,
|
|
error: "Suno music generation timed out",
|
|
}).catch(() => {});
|
|
return { success: false, status: 504, error: "Suno music generation timed out" };
|
|
} catch (err) {
|
|
if (log) log.error("MUSIC", `${provider} suno error: ${err.message}`);
|
|
saveCallLog({
|
|
method: "POST",
|
|
path: "/v1/music/generations",
|
|
status: 502,
|
|
model: `${provider}/${model}`,
|
|
provider,
|
|
duration: Date.now() - startTime,
|
|
error: err.message,
|
|
}).catch(() => {});
|
|
return {
|
|
success: false,
|
|
status: 502,
|
|
error: sanitizeErrorMessage(err) || "Music provider error",
|
|
};
|
|
}
|
|
}
|
|
|
|
async function handleUdioMusicGeneration({
|
|
model,
|
|
provider,
|
|
providerConfig,
|
|
body,
|
|
credentials,
|
|
log,
|
|
}) {
|
|
const startTime = Date.now();
|
|
const cookie = credentials?.apiKey || credentials?.providerSpecificData?.cookie || "";
|
|
if (!cookie) {
|
|
return { success: false, status: 401, error: "Udio session cookie is required" };
|
|
}
|
|
const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? "");
|
|
if (log) {
|
|
log.info("MUSIC", `${provider}/${model} (udio) | prompt: "${prompt.slice(0, 60)}..."`);
|
|
}
|
|
try {
|
|
const res = await fetch(providerConfig.baseUrl, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Cookie: cookie },
|
|
body: JSON.stringify({ prompt, samplerOptions: { seed: -1 } }),
|
|
});
|
|
if (!res.ok) {
|
|
const errorText = await res.text();
|
|
saveCallLog({
|
|
method: "POST",
|
|
path: "/v1/music/generations",
|
|
status: res.status,
|
|
model: `${provider}/${model}`,
|
|
provider,
|
|
duration: Date.now() - startTime,
|
|
error: errorText.slice(0, 500),
|
|
}).catch(() => {});
|
|
return { success: false, status: res.status, error: errorText };
|
|
}
|
|
const data = await res.json();
|
|
const trackIds = data.track_ids || [];
|
|
if (trackIds.length === 0) {
|
|
saveCallLog({
|
|
method: "POST",
|
|
path: "/v1/music/generations",
|
|
status: 502,
|
|
model: `${provider}/${model}`,
|
|
provider,
|
|
duration: Date.now() - startTime,
|
|
error: "No tracks returned from Udio",
|
|
}).catch(() => {});
|
|
return { success: false, status: 502, error: "No tracks returned from Udio" };
|
|
}
|
|
const deadline = Date.now() + 300000;
|
|
while (Date.now() < deadline) {
|
|
await new Promise((r) => setTimeout(r, 5000));
|
|
const statusRes = await fetch(
|
|
`https://www.udio.com/api/songs?songIds=${trackIds.join(",")}`,
|
|
{ headers: { Cookie: cookie } }
|
|
);
|
|
const songs = await statusRes.json();
|
|
const ready = songs.filter((s) => s.finished && s.song_path);
|
|
if (ready.length > 0) {
|
|
const audioRes = await fetch(ready[0].song_path);
|
|
if (!audioRes.ok) {
|
|
return {
|
|
success: false,
|
|
status: audioRes.status,
|
|
error: `Failed to download audio: ${audioRes.status}`,
|
|
};
|
|
}
|
|
const buf = await audioRes.arrayBuffer();
|
|
saveCallLog({
|
|
method: "POST",
|
|
path: "/v1/music/generations",
|
|
status: 200,
|
|
model: `${provider}/${model}`,
|
|
provider,
|
|
duration: Date.now() - startTime,
|
|
}).catch(() => {});
|
|
return {
|
|
success: true,
|
|
data: {
|
|
created: Math.floor(Date.now() / 1000),
|
|
data: [{ b64_json: Buffer.from(buf).toString("base64"), format: "mp3" }],
|
|
},
|
|
};
|
|
}
|
|
}
|
|
saveCallLog({
|
|
method: "POST",
|
|
path: "/v1/music/generations",
|
|
status: 504,
|
|
model: `${provider}/${model}`,
|
|
provider,
|
|
duration: Date.now() - startTime,
|
|
error: "Udio music generation timed out",
|
|
}).catch(() => {});
|
|
return { success: false, status: 504, error: "Udio music generation timed out" };
|
|
} catch (err) {
|
|
if (log) log.error("MUSIC", `${provider} udio error: ${err.message}`);
|
|
saveCallLog({
|
|
method: "POST",
|
|
path: "/v1/music/generations",
|
|
status: 502,
|
|
model: `${provider}/${model}`,
|
|
provider,
|
|
duration: Date.now() - startTime,
|
|
error: err.message,
|
|
}).catch(() => {});
|
|
return {
|
|
success: false,
|
|
status: 502,
|
|
error: sanitizeErrorMessage(err) || "Music provider error",
|
|
};
|
|
}
|
|
}
|