Fix review issues: lyrics polling, validation, undici fetch, retry, timeout, throttle, payload helper
This commit is contained in:
+84
-38
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Suno API HTTP client with rate limiting and polling helpers.
|
||||
* Suno API HTTP client with rate limiting, proxy support, retries and polling helpers.
|
||||
*
|
||||
* Rate limit: 20 requests per 10 seconds (sunoapi.org).
|
||||
* Proxy support via SUNO_HTTP_PROXY / HTTPS_PROXY / HTTP_PROXY env vars.
|
||||
*/
|
||||
|
||||
import { ProxyAgent } from 'undici';
|
||||
import { fetch, ProxyAgent } from 'undici';
|
||||
|
||||
export interface SunoApiResponse<T = unknown> {
|
||||
code: number;
|
||||
@@ -33,13 +33,20 @@ export interface SunoTrack {
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface SunoLyricsVariant {
|
||||
text?: string;
|
||||
title?: string;
|
||||
status?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface SunoTaskData {
|
||||
taskId: string;
|
||||
parentMusicId?: string;
|
||||
param?: string;
|
||||
response?: {
|
||||
taskId?: string;
|
||||
data?: SunoTrack[];
|
||||
data?: SunoTrack[] | SunoLyricsVariant[];
|
||||
sunoData?: SunoTrack[];
|
||||
};
|
||||
status: string;
|
||||
@@ -59,12 +66,26 @@ function getProxyUrl(): string | undefined {
|
||||
);
|
||||
}
|
||||
|
||||
function isRetryableError(error: unknown): boolean {
|
||||
if (error instanceof Error) {
|
||||
const msg = error.message.toLowerCase();
|
||||
return msg.includes('timeout') || msg.includes('econnreset') || msg.includes('socket') || msg.includes('fetch failed');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export class SunoClient {
|
||||
private readonly baseUrl = 'https://api.sunoapi.org/api/v1';
|
||||
private readonly apiKey: string;
|
||||
private readonly maxRequests = 20;
|
||||
private readonly windowMs = 10_000;
|
||||
private readonly proxyAgent?: ProxyAgent;
|
||||
private readonly requestTimeoutMs = 30_000;
|
||||
private readonly maxRetries = 2;
|
||||
private requestTimestamps: number[] = [];
|
||||
|
||||
constructor(apiKey: string) {
|
||||
@@ -79,21 +100,19 @@ export class SunoClient {
|
||||
* Enforce rate limit: max 20 requests per 10 seconds.
|
||||
*/
|
||||
private async throttle(): Promise<void> {
|
||||
const now = Date.now();
|
||||
this.requestTimestamps = this.requestTimestamps.filter(
|
||||
(ts) => now - ts < this.windowMs
|
||||
);
|
||||
while (true) {
|
||||
const now = Date.now();
|
||||
this.requestTimestamps = this.requestTimestamps.filter((ts) => now - ts < this.windowMs);
|
||||
|
||||
if (this.requestTimestamps.length >= this.maxRequests) {
|
||||
const oldest = this.requestTimestamps[0];
|
||||
const wait = this.windowMs - (now - oldest);
|
||||
if (wait > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, wait));
|
||||
return this.throttle();
|
||||
if (this.requestTimestamps.length < this.maxRequests) {
|
||||
this.requestTimestamps.push(now);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.requestTimestamps.push(now);
|
||||
const oldest = this.requestTimestamps[0];
|
||||
const wait = Math.max(0, this.windowMs - (now - oldest));
|
||||
await sleep(wait);
|
||||
}
|
||||
}
|
||||
|
||||
async request<T = unknown>(
|
||||
@@ -109,30 +128,56 @@ export class SunoClient {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
const fetchOptions: RequestInit & { dispatcher?: any } = {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
};
|
||||
if (this.proxyAgent) {
|
||||
fetchOptions.dispatcher = this.proxyAgent;
|
||||
let lastError: Error | undefined;
|
||||
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
||||
try {
|
||||
const fetchOptions: RequestInit & { dispatcher?: ProxyAgent } = {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
||||
};
|
||||
if (this.proxyAgent) {
|
||||
fetchOptions.dispatcher = this.proxyAgent;
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions);
|
||||
const text = await response.text();
|
||||
|
||||
let json: SunoApiResponse<T>;
|
||||
try {
|
||||
json = JSON.parse(text) as SunoApiResponse<T>;
|
||||
} catch {
|
||||
throw new Error(`Invalid JSON response from Suno API: ${text}`);
|
||||
}
|
||||
|
||||
// Retry on transient server errors / rate-limit before throwing.
|
||||
if (json.code === 429 || json.code === 503 || json.code === 504) {
|
||||
if (attempt < this.maxRetries) {
|
||||
const backoff = Math.min(1000 * 2 ** attempt, 8000);
|
||||
await sleep(backoff);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (json.code !== 200) {
|
||||
throw new Error(`Suno API error ${json.code}: ${json.msg || 'unknown error'}`);
|
||||
}
|
||||
|
||||
return json;
|
||||
} catch (error: unknown) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
const retryable = isRetryableError(lastError);
|
||||
if (retryable && attempt < this.maxRetries) {
|
||||
const backoff = Math.min(1000 * 2 ** attempt, 8000);
|
||||
await sleep(backoff);
|
||||
continue;
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions);
|
||||
|
||||
const text = await response.text();
|
||||
let json: SunoApiResponse<T>;
|
||||
try {
|
||||
json = JSON.parse(text) as SunoApiResponse<T>;
|
||||
} catch {
|
||||
throw new Error(`Invalid JSON response from Suno API: ${text}`);
|
||||
}
|
||||
|
||||
if (json.code !== 200) {
|
||||
throw new Error(`Suno API error ${json.code}: ${json.msg || 'unknown error'}`);
|
||||
}
|
||||
|
||||
return json;
|
||||
throw lastError ?? new Error('Suno API request failed');
|
||||
}
|
||||
|
||||
get<T = unknown>(path: string): Promise<SunoApiResponse<T>> {
|
||||
@@ -167,6 +212,7 @@ export class SunoClient {
|
||||
status === 'FAILED' ||
|
||||
status === 'CREATE_TASK_FAILED' ||
|
||||
status === 'GENERATE_AUDIO_FAILED' ||
|
||||
status === 'GENERATE_LYRICS_FAILED' ||
|
||||
status === 'CALLBACK_EXCEPTION' ||
|
||||
status === 'SENSITIVE_WORD_ERROR'
|
||||
) {
|
||||
@@ -174,7 +220,7 @@ export class SunoClient {
|
||||
}
|
||||
|
||||
if (attempt < maxAttempts) {
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+125
-75
@@ -1,8 +1,10 @@
|
||||
import { SunoClient, SunoTaskData } from './client';
|
||||
import { SunoClient, SunoLyricsVariant, SunoTaskData, SunoTrack } from './client';
|
||||
import * as schemas from './schemas';
|
||||
|
||||
const ASYNC_POLLING_PATHS: Record<string, string> = {
|
||||
'suno_generate_music': '/generate/record-info?taskId=',
|
||||
'suno_generate_lyrics': '/lyrics/record-info?taskId=',
|
||||
'suno_generate_sounds': '/generate/record-info?taskId=',
|
||||
'suno_extend_music': '/generate/record-info?taskId=',
|
||||
'suno_replace_section': '/generate/record-info?taskId=',
|
||||
'suno_upload_and_cover': '/generate/record-info?taskId=',
|
||||
@@ -15,22 +17,80 @@ const ASYNC_POLLING_PATHS: Record<string, string> = {
|
||||
'suno_create_video': '/generate/video-info?taskId=',
|
||||
'suno_create_cover': '/generate/cover-info?taskId=',
|
||||
'suno_convert_to_wav': '/generate/wav-info?taskId=',
|
||||
'suno_generate_sounds': '/generate/record-info?taskId=',
|
||||
};
|
||||
|
||||
function formatTaskResult(data: SunoTaskData): string {
|
||||
function copyDefined<T extends Record<string, unknown>>(
|
||||
source: T,
|
||||
target: Record<string, unknown>,
|
||||
keys: (keyof T)[]
|
||||
): void {
|
||||
for (const key of keys) {
|
||||
const value = source[key];
|
||||
if (value !== undefined) {
|
||||
target[key as string] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isLyricsData(data: SunoTaskData): boolean {
|
||||
return data.type === 'LYRICS';
|
||||
}
|
||||
|
||||
function formatLyricsResult(data: SunoTaskData): string {
|
||||
const status = data.status;
|
||||
if (status !== 'SUCCESS') {
|
||||
return JSON.stringify({
|
||||
return JSON.stringify(
|
||||
{
|
||||
taskId: data.taskId,
|
||||
status,
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
const variants = (data.response?.data as SunoLyricsVariant[] | undefined) ?? [];
|
||||
return JSON.stringify(
|
||||
{
|
||||
taskId: data.taskId,
|
||||
status,
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
}, null, 2);
|
||||
type: data.type,
|
||||
variants: variants.map((v, index) => ({
|
||||
index: index + 1,
|
||||
title: v.title,
|
||||
text: v.text,
|
||||
status: v.status,
|
||||
errorMessage: v.errorMessage,
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
function formatTaskResult(data: SunoTaskData): string {
|
||||
if (isLyricsData(data)) {
|
||||
return formatLyricsResult(data);
|
||||
}
|
||||
|
||||
const status = data.status;
|
||||
if (status !== 'SUCCESS') {
|
||||
return JSON.stringify(
|
||||
{
|
||||
taskId: data.taskId,
|
||||
status,
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
const response = data.response;
|
||||
const tracks = response?.data ?? response?.sunoData ?? [];
|
||||
const tracks = (response?.data ?? response?.sunoData ?? []) as SunoTrack[];
|
||||
return JSON.stringify(
|
||||
{
|
||||
taskId: data.taskId,
|
||||
@@ -83,32 +143,33 @@ export class ToolHandlers {
|
||||
case 'suno_generate_music': {
|
||||
const input = schemas.GenerateMusicSchema.parse(args);
|
||||
const payload: Record<string, unknown> = {
|
||||
prompt: input.prompt,
|
||||
customMode: input.customMode,
|
||||
instrumental: input.instrumental,
|
||||
model: input.model,
|
||||
callBackUrl: input.callBackUrl ?? '',
|
||||
};
|
||||
if (input.style !== undefined) payload.style = input.style;
|
||||
if (input.title !== undefined) payload.title = input.title;
|
||||
if (input.negativeTags !== undefined) payload.negativeTags = input.negativeTags;
|
||||
if (input.vocalGender !== undefined) payload.vocalGender = input.vocalGender;
|
||||
if (input.styleWeight !== undefined) payload.styleWeight = input.styleWeight;
|
||||
if (input.weirdnessConstraint !== undefined) payload.weirdnessConstraint = input.weirdnessConstraint;
|
||||
if (input.audioWeight !== undefined) payload.audioWeight = input.audioWeight;
|
||||
if (input.personaId !== undefined) payload.personaId = input.personaId;
|
||||
if (input.personaModel !== undefined) payload.personaModel = input.personaModel;
|
||||
copyDefined(input, payload, [
|
||||
'prompt',
|
||||
'style',
|
||||
'title',
|
||||
'negativeTags',
|
||||
'vocalGender',
|
||||
'styleWeight',
|
||||
'weirdnessConstraint',
|
||||
'audioWeight',
|
||||
'personaId',
|
||||
'personaModel',
|
||||
]);
|
||||
resultText = await runAsyncTask(this.client, name, '/generate', payload);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'suno_generate_lyrics': {
|
||||
const input = schemas.GenerateLyricsSchema.parse(args);
|
||||
const result = await this.client.post<{ taskId: string }>('/lyrics', {
|
||||
resultText = await runAsyncTask(this.client, name, '/lyrics', {
|
||||
prompt: input.prompt,
|
||||
callBackUrl: input.callBackUrl ?? '',
|
||||
});
|
||||
resultText = JSON.stringify({ taskId: result.data.taskId, status: 'PENDING' }, null, 2);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -119,10 +180,7 @@ export class ToolHandlers {
|
||||
model: input.model,
|
||||
callBackUrl: input.callBackUrl ?? '',
|
||||
};
|
||||
if (input.soundLoop !== undefined) payload.soundLoop = input.soundLoop;
|
||||
if (input.soundTempo !== undefined) payload.soundTempo = input.soundTempo;
|
||||
if (input.soundKey !== undefined) payload.soundKey = input.soundKey;
|
||||
if (input.grabLyrics !== undefined) payload.grabLyrics = input.grabLyrics;
|
||||
copyDefined(input, payload, ['soundLoop', 'soundTempo', 'soundKey', 'grabLyrics']);
|
||||
resultText = await runAsyncTask(this.client, name, '/generate/sounds', payload);
|
||||
break;
|
||||
}
|
||||
@@ -135,17 +193,19 @@ export class ToolHandlers {
|
||||
model: input.model,
|
||||
callBackUrl: input.callBackUrl ?? '',
|
||||
};
|
||||
if (input.prompt !== undefined) payload.prompt = input.prompt;
|
||||
if (input.style !== undefined) payload.style = input.style;
|
||||
if (input.title !== undefined) payload.title = input.title;
|
||||
if (input.continueAt !== undefined) payload.continueAt = input.continueAt;
|
||||
if (input.negativeTags !== undefined) payload.negativeTags = input.negativeTags;
|
||||
if (input.vocalGender !== undefined) payload.vocalGender = input.vocalGender;
|
||||
if (input.styleWeight !== undefined) payload.styleWeight = input.styleWeight;
|
||||
if (input.weirdnessConstraint !== undefined) payload.weirdnessConstraint = input.weirdnessConstraint;
|
||||
if (input.audioWeight !== undefined) payload.audioWeight = input.audioWeight;
|
||||
if (input.personaId !== undefined) payload.personaId = input.personaId;
|
||||
if (input.personaModel !== undefined) payload.personaModel = input.personaModel;
|
||||
copyDefined(input, payload, [
|
||||
'prompt',
|
||||
'style',
|
||||
'title',
|
||||
'continueAt',
|
||||
'negativeTags',
|
||||
'vocalGender',
|
||||
'styleWeight',
|
||||
'weirdnessConstraint',
|
||||
'audioWeight',
|
||||
'personaId',
|
||||
'personaModel',
|
||||
]);
|
||||
resultText = await runAsyncTask(this.client, name, '/generate/extend', payload);
|
||||
break;
|
||||
}
|
||||
@@ -160,11 +220,7 @@ export class ToolHandlers {
|
||||
model: input.model,
|
||||
callBackUrl: input.callBackUrl ?? '',
|
||||
};
|
||||
if (input.replaceAudioUrl !== undefined) payload.replaceAudioUrl = input.replaceAudioUrl;
|
||||
if (input.customMode !== undefined) payload.customMode = input.customMode;
|
||||
if (input.style !== undefined) payload.style = input.style;
|
||||
if (input.title !== undefined) payload.title = input.title;
|
||||
if (input.instrumental !== undefined) payload.instrumental = input.instrumental;
|
||||
copyDefined(input, payload, ['replaceAudioUrl', 'customMode', 'style', 'title', 'instrumental']);
|
||||
resultText = await runAsyncTask(this.client, name, '/generate/replace-section', payload);
|
||||
break;
|
||||
}
|
||||
@@ -173,13 +229,10 @@ export class ToolHandlers {
|
||||
const input = schemas.AddVocalsSchema.parse(args);
|
||||
const payload: Record<string, unknown> = {
|
||||
audioId: input.audioId,
|
||||
prompt: input.prompt,
|
||||
model: input.model,
|
||||
callBackUrl: input.callBackUrl ?? '',
|
||||
};
|
||||
if (input.customMode !== undefined) payload.customMode = input.customMode;
|
||||
if (input.style !== undefined) payload.style = input.style;
|
||||
if (input.title !== undefined) payload.title = input.title;
|
||||
copyDefined(input, payload, ['prompt', 'customMode', 'style', 'title']);
|
||||
resultText = await runAsyncTask(this.client, name, '/generate/add-vocals', payload);
|
||||
break;
|
||||
}
|
||||
@@ -188,13 +241,10 @@ export class ToolHandlers {
|
||||
const input = schemas.AddInstrumentalSchema.parse(args);
|
||||
const payload: Record<string, unknown> = {
|
||||
audioId: input.audioId,
|
||||
prompt: input.prompt,
|
||||
model: input.model,
|
||||
callBackUrl: input.callBackUrl ?? '',
|
||||
};
|
||||
if (input.customMode !== undefined) payload.customMode = input.customMode;
|
||||
if (input.style !== undefined) payload.style = input.style;
|
||||
if (input.title !== undefined) payload.title = input.title;
|
||||
copyDefined(input, payload, ['prompt', 'customMode', 'style', 'title']);
|
||||
resultText = await runAsyncTask(this.client, name, '/generate/add-instrumental', payload);
|
||||
break;
|
||||
}
|
||||
@@ -208,16 +258,18 @@ export class ToolHandlers {
|
||||
model: input.model,
|
||||
callBackUrl: input.callBackUrl ?? '',
|
||||
};
|
||||
if (input.prompt !== undefined) payload.prompt = input.prompt;
|
||||
if (input.style !== undefined) payload.style = input.style;
|
||||
if (input.title !== undefined) payload.title = input.title;
|
||||
if (input.negativeTags !== undefined) payload.negativeTags = input.negativeTags;
|
||||
if (input.vocalGender !== undefined) payload.vocalGender = input.vocalGender;
|
||||
if (input.styleWeight !== undefined) payload.styleWeight = input.styleWeight;
|
||||
if (input.weirdnessConstraint !== undefined) payload.weirdnessConstraint = input.weirdnessConstraint;
|
||||
if (input.audioWeight !== undefined) payload.audioWeight = input.audioWeight;
|
||||
if (input.personaId !== undefined) payload.personaId = input.personaId;
|
||||
if (input.personaModel !== undefined) payload.personaModel = input.personaModel;
|
||||
copyDefined(input, payload, [
|
||||
'prompt',
|
||||
'style',
|
||||
'title',
|
||||
'negativeTags',
|
||||
'vocalGender',
|
||||
'styleWeight',
|
||||
'weirdnessConstraint',
|
||||
'audioWeight',
|
||||
'personaId',
|
||||
'personaModel',
|
||||
]);
|
||||
resultText = await runAsyncTask(this.client, name, '/generate/upload-cover', payload);
|
||||
break;
|
||||
}
|
||||
@@ -230,10 +282,7 @@ export class ToolHandlers {
|
||||
model: input.model,
|
||||
callBackUrl: input.callBackUrl ?? '',
|
||||
};
|
||||
if (input.prompt !== undefined) payload.prompt = input.prompt;
|
||||
if (input.style !== undefined) payload.style = input.style;
|
||||
if (input.title !== undefined) payload.title = input.title;
|
||||
if (input.continueAt !== undefined) payload.continueAt = input.continueAt;
|
||||
copyDefined(input, payload, ['prompt', 'style', 'title', 'continueAt']);
|
||||
resultText = await runAsyncTask(this.client, name, '/generate/upload-extend', payload);
|
||||
break;
|
||||
}
|
||||
@@ -247,14 +296,16 @@ export class ToolHandlers {
|
||||
model: input.model,
|
||||
callBackUrl: input.callBackUrl ?? '',
|
||||
};
|
||||
if (input.instrumental !== undefined) payload.instrumental = input.instrumental;
|
||||
if (input.style !== undefined) payload.style = input.style;
|
||||
if (input.title !== undefined) payload.title = input.title;
|
||||
if (input.negativeTags !== undefined) payload.negativeTags = input.negativeTags;
|
||||
if (input.vocalGender !== undefined) payload.vocalGender = input.vocalGender;
|
||||
if (input.styleWeight !== undefined) payload.styleWeight = input.styleWeight;
|
||||
if (input.weirdnessConstraint !== undefined) payload.weirdnessConstraint = input.weirdnessConstraint;
|
||||
if (input.audioWeight !== undefined) payload.audioWeight = input.audioWeight;
|
||||
copyDefined(input, payload, [
|
||||
'instrumental',
|
||||
'style',
|
||||
'title',
|
||||
'negativeTags',
|
||||
'vocalGender',
|
||||
'styleWeight',
|
||||
'weirdnessConstraint',
|
||||
'audioWeight',
|
||||
]);
|
||||
resultText = await runAsyncTask(this.client, name, '/generate/mashup', payload);
|
||||
break;
|
||||
}
|
||||
@@ -287,8 +338,7 @@ export class ToolHandlers {
|
||||
audioId: input.audioId,
|
||||
callBackUrl: input.callBackUrl ?? '',
|
||||
};
|
||||
if (input.author !== undefined) payload.author = input.author;
|
||||
if (input.domainName !== undefined) payload.domainName = input.domainName;
|
||||
copyDefined(input, payload, ['author', 'domainName']);
|
||||
resultText = await runAsyncTask(this.client, name, '/generate/video', payload);
|
||||
break;
|
||||
}
|
||||
@@ -353,10 +403,10 @@ export class ToolHandlers {
|
||||
|
||||
case 'suno_get_lyrics_details': {
|
||||
const input = schemas.GetLyricsDetailsSchema.parse(args);
|
||||
const result = await this.client.get<unknown>(
|
||||
`/generate/lyrics-info?taskId=${encodeURIComponent(input.taskId)}`
|
||||
const result = await this.client.get<SunoTaskData>(
|
||||
`/lyrics/record-info?taskId=${encodeURIComponent(input.taskId)}`
|
||||
);
|
||||
resultText = JSON.stringify(result.data, null, 2);
|
||||
resultText = formatLyricsResult(result.data);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
+69
-26
@@ -22,18 +22,50 @@ const CommonGenerateParams = {
|
||||
};
|
||||
|
||||
// 1. Generate Music (12 credits)
|
||||
export const GenerateMusicSchema = z.object({
|
||||
prompt: z.string().optional().describe('Music description / lyrics (max 500 chars non-custom, 3000-5000 custom mode; required when customMode=false or customMode=true with instrumental=false)'),
|
||||
customMode: z.boolean().describe('Enable custom mode (requires style and title)'),
|
||||
instrumental: z.boolean().describe('Generate instrumental without vocals'),
|
||||
style: z.string().optional().describe('Music style/genre (required when customMode=true)'),
|
||||
title: z.string().optional().describe('Song title (required when customMode=true)'),
|
||||
...CommonGenerateParams,
|
||||
}).strict();
|
||||
export const GenerateMusicSchema = z
|
||||
.object({
|
||||
prompt: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Music description / lyrics (max 500 chars non-custom, 3000-5000 custom mode; required when customMode=false or customMode=true with instrumental=false)'
|
||||
),
|
||||
customMode: z.boolean().describe('Enable custom mode (requires style and title)'),
|
||||
instrumental: z.boolean().describe('Generate instrumental without vocals'),
|
||||
style: z.string().optional().describe('Music style/genre (required when customMode=true)'),
|
||||
title: z.string().optional().describe('Song title (required when customMode=true)'),
|
||||
...CommonGenerateParams,
|
||||
})
|
||||
.strict()
|
||||
.superRefine(requirePromptWhenNeeded)
|
||||
.refine(
|
||||
(data) => !data.customMode || (!!data.style && !!data.title),
|
||||
{
|
||||
message: 'style and title are required when customMode is true',
|
||||
path: ['style'],
|
||||
}
|
||||
);
|
||||
|
||||
function requirePromptWhenNeeded(data: { customMode: boolean; instrumental: boolean; prompt?: string }, ctx: z.RefinementCtx): void {
|
||||
if (!data.customMode && !data.prompt) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'prompt is required when customMode is false',
|
||||
path: ['prompt'],
|
||||
});
|
||||
}
|
||||
if (data.customMode && !data.instrumental && !data.prompt) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'prompt is required when customMode is true and instrumental is false',
|
||||
path: ['prompt'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Generate Lyrics (0.4 credits)
|
||||
export const GenerateLyricsSchema = z.object({
|
||||
prompt: z.string().describe('Topic or theme for lyrics'),
|
||||
prompt: z.string().max(200).describe('Topic or theme for lyrics (max 200 chars)'),
|
||||
callBackUrl: CallbackUrl,
|
||||
}).strict();
|
||||
|
||||
@@ -108,23 +140,33 @@ export const AddInstrumentalSchema = z.object({
|
||||
}).strict();
|
||||
|
||||
// 8. Upload and Cover (12 credits)
|
||||
export const UploadAndCoverSchema = z.object({
|
||||
uploadUrl: z.string().url().describe('Public URL of audio file to cover (max 8 minutes)'),
|
||||
prompt: z.string().optional(),
|
||||
customMode: z.boolean(),
|
||||
instrumental: z.boolean(),
|
||||
style: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
model: ModelEnum,
|
||||
negativeTags: z.string().optional(),
|
||||
vocalGender: z.enum(['m', 'f']).optional(),
|
||||
styleWeight: z.number().min(0).max(1).optional(),
|
||||
weirdnessConstraint: z.number().min(0).max(1).optional(),
|
||||
audioWeight: z.number().min(0).max(1).optional(),
|
||||
personaId: z.string().optional(),
|
||||
personaModel: PersonaModelEnum,
|
||||
callBackUrl: CallbackUrl,
|
||||
}).strict();
|
||||
export const UploadAndCoverSchema = z
|
||||
.object({
|
||||
uploadUrl: z.string().url().describe('Public URL of audio file to cover (max 8 minutes)'),
|
||||
prompt: z.string().optional(),
|
||||
customMode: z.boolean(),
|
||||
instrumental: z.boolean(),
|
||||
style: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
model: ModelEnum,
|
||||
negativeTags: z.string().optional(),
|
||||
vocalGender: z.enum(['m', 'f']).optional(),
|
||||
styleWeight: z.number().min(0).max(1).optional(),
|
||||
weirdnessConstraint: z.number().min(0).max(1).optional(),
|
||||
audioWeight: z.number().min(0).max(1).optional(),
|
||||
personaId: z.string().optional(),
|
||||
personaModel: PersonaModelEnum,
|
||||
callBackUrl: CallbackUrl,
|
||||
})
|
||||
.strict()
|
||||
.superRefine(requirePromptWhenNeeded)
|
||||
.refine(
|
||||
(data) => !data.customMode || (!!data.style && !!data.title),
|
||||
{
|
||||
message: 'style and title are required when customMode is true',
|
||||
path: ['style'],
|
||||
}
|
||||
);
|
||||
|
||||
// 9. Upload and Extend (12 credits)
|
||||
export const UploadAndExtendSchema = z.object({
|
||||
@@ -187,6 +229,7 @@ export const GeneratePersonaSchema = z.object({
|
||||
}).strict();
|
||||
|
||||
// 17. Generate Mashup (12 credits)
|
||||
// Note: Suno API docs list only V4/V4_5/V4_5PLUS/V4_5ALL/V5 for mashup; V5_5 is not supported.
|
||||
export const GenerateMashupSchema = z.object({
|
||||
uploadUrlList: z.array(z.string().url()).length(2).describe('Exactly 2 audio URLs to mash up'),
|
||||
customMode: z.boolean(),
|
||||
|
||||
Reference in New Issue
Block a user