diff --git a/changelog.d/fixes/minimax-music-generation-dispatch.md b/changelog.d/fixes/minimax-music-generation-dispatch.md new file mode 100644 index 0000000000..e0cf2114ca --- /dev/null +++ b/changelog.d/fixes/minimax-music-generation-dispatch.md @@ -0,0 +1 @@ +- **fix(sse):** MiniMax music models now generate audio instead of failing with `Unsupported music format: minimax-music` — the provider entry was registered in the music registry (and advertised by `/v1/models`), but `handleMusicGeneration` had no branch for its format, so every `minimax/*` music request fell through the dispatch chain to a 400. Adds the missing dispatch: a single synchronous POST with the `base_resp` envelope check (a non-zero `status_code` arrives on HTTP 200 too), `data.status` handling (an unfinished generation is reported instead of polled — the operation has no task id and no query endpoint), `url` and `hex` output formats (hex normalized to base64), `mp3`/`wav`/`pcm` containers via `audio_setting`, and the regional endpoint through the per-connection base-URL override, which is also the only host that accepts `aigc_watermark`. The registry entry gains the generation and cover model ids it was missing and drops a query URL that does not exist for this operation. Regression guard: `tests/unit/minimax-music-generation.test.ts` (9 tests). diff --git a/open-sse/config/musicRegistry.ts b/open-sse/config/musicRegistry.ts index 06aa8a159b..fd1f88fd88 100644 --- a/open-sse/config/musicRegistry.ts +++ b/open-sse/config/musicRegistry.ts @@ -17,6 +17,8 @@ interface MusicProvider { id: string; baseUrl: string; statusUrl?: string; + /** Regional deployment of the same contract, reachable via a base-URL override. */ + regionalBaseUrl?: string; authType: string; authHeader: string; format: string; @@ -79,14 +81,21 @@ export const MUSIC_PROVIDERS: Record = { minimax: { id: "minimax", baseUrl: "https://api.minimax.io/v1/music_generation", - statusUrl: "https://api.minimax.io/v1/query/music_generation", + // The music operation answers with the finished audio in the POST response — + // there is no task id and no query endpoint, hence no statusUrl. The regional + // deployment serves the same contract and is the only host that accepts the + // `aigc_watermark` request field. + regionalBaseUrl: "https://api.minimaxi.com/v1/music_generation", authType: "apikey", authHeader: "bearer", format: "minimax-music", models: [ + { id: "music-3.0", name: "Music 3.0" }, { id: "music-2.6", name: "Music 2.6" }, + { id: "music-3.0-free", name: "Music 3.0 Free" }, { id: "music-2.6-free", name: "Music 2.6 Free" }, { id: "music-cover", name: "Music Cover" }, + { id: "music-cover-free", name: "Music Cover Free" }, ], }, comfyui: { diff --git a/open-sse/handlers/mediaGeneration/minimaxMusic.ts b/open-sse/handlers/mediaGeneration/minimaxMusic.ts new file mode 100644 index 0000000000..3486676058 --- /dev/null +++ b/open-sse/handlers/mediaGeneration/minimaxMusic.ts @@ -0,0 +1,358 @@ +/** + * MiniMax music generation handler (format: "minimax-music"). + * + * The provider entry has been in musicRegistry since the media registries were + * introduced, but handleMusicGeneration never grew a branch for its format — so + * every registered `minimax/*` music model fell through the dispatch chain to + * `Unsupported music format: minimax-music` (400) and the models were + * advertised by /v1/models while being impossible to call. + * + * The upstream contract is a single synchronous POST — unlike the vendor's + * task-based media endpoints there is no task id and no query endpoint, so a + * request is either finished (`data.status` 2, audio in `data.audio`) or still + * generating (`data.status` 1), which can only be reported back, never awaited. + * Failures are carried in the `base_resp` envelope (`status_code` 0 = success) + * even on HTTP 200. + * + * `output_format` selects how the audio comes back: `url` (a short-lived link, + * valid for 24h — callers must download it before it expires) or `hex` (the raw + * container inline, normalized here to base64 so the response matches the + * OpenAI-shaped payload the other music branches return). + */ + +import { saveCallLog } from "@/lib/usageDb"; +import { sanitizeErrorMessage } from "../../utils/error.ts"; + +type MinimaxMusicBody = Record; + +interface MinimaxMusicProviderConfig { + baseUrl: string; + /** Regional deployment of the same contract — see resolveEndpoint below. */ + regionalBaseUrl?: string; +} + +interface MinimaxMusicCredentials { + apiKey?: unknown; + accessToken?: unknown; + providerSpecificData?: { baseUrl?: unknown } | null; +} + +interface MinimaxMusicLog { + info?: (scope: string, message: string) => void; + error?: (scope: string, message: string) => void; +} + +interface MinimaxMusicArgs { + model: string; + provider: string; + providerConfig: MinimaxMusicProviderConfig; + body: MinimaxMusicBody; + credentials?: MinimaxMusicCredentials | null; + log?: MinimaxMusicLog | null; +} + +/** Containers accepted by `audio_setting.format`. */ +const AUDIO_FORMATS = new Set(["mp3", "wav", "pcm"]); +/** Accepted `output_format` values. */ +const OUTPUT_FORMATS = new Set(["url", "hex"]); +/** Container assumed when the request does not pin `audio_setting.format`. */ +const DEFAULT_AUDIO_FORMAT = "mp3"; +/** `data.status`: 1 = still generating, 2 = finished. */ +const STATUS_IN_PROGRESS = 1; +/** String request fields forwarded verbatim when the caller provides them. */ +const STRING_REQUEST_FIELDS = [ + "prompt", + "lyrics", + "audio_url", + "audio_base64", + "cover_feature_id", +] as const; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function numberValue(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function booleanValue(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +/** Fire-and-forget usage log for a MiniMax music-generation call. */ +function logMinimaxMusicCall(params: { + status: number; + model: string; + provider: string; + duration: number; + error?: string; + requestBody?: unknown; + responseBody?: unknown; +}): void { + saveCallLog({ + method: "POST", + path: "/v1/music/generations", + ...params, + }).catch(() => {}); +} + +/** + * Endpoint for this call: the per-connection `providerSpecificData.baseUrl` + * override (the same storage every configurable-base-URL provider uses) wins + * over the registry default. That override is how a connection targets the + * regional deployment declared as `regionalBaseUrl`. + */ +function resolveEndpoint( + providerConfig: MinimaxMusicProviderConfig, + credentials?: MinimaxMusicCredentials | null +): string { + const psd = credentials?.providerSpecificData; + const override = isRecord(psd) ? stringValue(psd.baseUrl) : undefined; + return override || providerConfig.baseUrl; +} + +/** True when `endpoint` is the regional deployment declared by the registry. */ +function isRegionalEndpoint(endpoint: string, regionalBaseUrl?: string): boolean { + if (!regionalBaseUrl) return false; + try { + return new URL(endpoint).host === new URL(regionalBaseUrl).host; + } catch { + return false; + } +} + +/** Forwards only the recognized `audio_setting` members, dropping unknown containers. */ +function buildAudioSetting(body: MinimaxMusicBody): Record | undefined { + const provided: Record = isRecord(body.audio_setting) ? body.audio_setting : {}; + const setting: Record = {}; + + const sampleRate = numberValue(provided.sample_rate); + if (sampleRate !== undefined) setting.sample_rate = sampleRate; + + const bitrate = numberValue(provided.bitrate); + if (bitrate !== undefined) setting.bitrate = bitrate; + + const format = stringValue(provided.format)?.toLowerCase(); + if (format && AUDIO_FORMATS.has(format)) setting.format = format; + + return Object.keys(setting).length > 0 ? setting : undefined; +} + +/** Container reported back to the caller — mirrors what was asked upstream. */ +function resolveAudioFormat(body: MinimaxMusicBody): string { + const provided: Record = isRecord(body.audio_setting) ? body.audio_setting : {}; + const format = stringValue(provided.format)?.toLowerCase(); + return format && AUDIO_FORMATS.has(format) ? format : DEFAULT_AUDIO_FORMAT; +} + +function resolveOutputFormat(body: MinimaxMusicBody): string { + const requested = stringValue(body.output_format)?.toLowerCase(); + return requested && OUTPUT_FORMATS.has(requested) ? requested : "url"; +} + +/** + * Upstream request body. `stream` is pinned false: this route answers with a + * single JSON payload, and streaming responses would also be restricted to the + * hex output format. + */ +function buildUpstreamBody( + model: string, + body: MinimaxMusicBody, + regional: boolean +): Record { + const request: Record = { + model, + stream: false, + output_format: resolveOutputFormat(body), + }; + + for (const field of STRING_REQUEST_FIELDS) { + const value = stringValue(body[field]); + if (value !== undefined) request[field] = value; + } + + const audioSetting = buildAudioSetting(body); + if (audioSetting) request.audio_setting = audioSetting; + + const lyricsOptimizer = booleanValue(body.lyrics_optimizer); + if (lyricsOptimizer !== undefined) request.lyrics_optimizer = lyricsOptimizer; + + // `instrumental` is the spelling the other music branches already accept. + const isInstrumental = booleanValue(body.is_instrumental) ?? booleanValue(body.instrumental); + if (isInstrumental !== undefined) request.is_instrumental = isInstrumental; + + // Only the regional endpoint accepts a watermark flag. + if (regional) { + const watermark = booleanValue(body.aigc_watermark); + if (watermark !== undefined) request.aigc_watermark = watermark; + } + + return request; +} + +async function readPayload(response: Response): Promise> { + const rawText = await response.text(); + if (!rawText) return {}; + try { + const parsed: unknown = JSON.parse(rawText); + return isRecord(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +/** Hex payloads are normalized to base64; Buffer would silently drop bad nibbles. */ +function hexAudioToBase64(audioHex: string): string { + if (audioHex.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(audioHex)) { + throw new Error("MiniMax music generation returned invalid hex audio"); + } + return Buffer.from(audioHex, "hex").toString("base64"); +} + +/** `base_resp.status_code` is non-zero on failures that still answer HTTP 200. */ +function readEnvelopeError(payload: Record): string | undefined { + const baseResp: Record = isRecord(payload.base_resp) ? payload.base_resp : {}; + const statusCode = numberValue(baseResp.status_code); + if (statusCode === undefined || statusCode === 0) return undefined; + return stringValue(baseResp.status_msg) || `upstream status code ${statusCode}`; +} + +export async function handleMinimaxMusicGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}: MinimaxMusicArgs) { + const startTime = Date.now(); + const token = stringValue(credentials?.apiKey) || stringValue(credentials?.accessToken); + if (!token) { + return { success: false as const, status: 401, error: "MiniMax API key is required" }; + } + + const modelId = stringValue(model); + if (!modelId) { + return { success: false as const, status: 400, error: "MiniMax music model is required" }; + } + + const endpoint = resolveEndpoint(providerConfig, credentials); + const upstreamBody = buildUpstreamBody( + modelId, + body, + isRegionalEndpoint(endpoint, providerConfig.regionalBaseUrl) + ); + const audioFormat = resolveAudioFormat(body); + const modelLabel = `${provider}/${modelId}`; + + log?.info?.( + "MUSIC", + `${modelLabel} (minimax-music) | prompt: "${String(body.prompt ?? "").slice(0, 60)}..." | ` + + `output_format: ${upstreamBody.output_format} | audio_format: ${audioFormat}` + ); + + try { + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(upstreamBody), + }); + + const payload = await readPayload(response); + + if (!response.ok) { + const errorMessage = + readEnvelopeError(payload) || `MiniMax music generation failed (${response.status})`; + log?.error?.("MUSIC", `${provider} minimax-music error ${response.status}: ${errorMessage}`); + logMinimaxMusicCall({ + status: response.status, + model: modelLabel, + provider, + duration: Date.now() - startTime, + error: errorMessage, + requestBody: upstreamBody, + }); + return { success: false as const, status: response.status, error: errorMessage }; + } + + const envelopeError = readEnvelopeError(payload); + if (envelopeError) { + log?.error?.("MUSIC", `${provider} minimax-music rejected the request: ${envelopeError}`); + logMinimaxMusicCall({ + status: 502, + model: modelLabel, + provider, + duration: Date.now() - startTime, + error: envelopeError, + requestBody: upstreamBody, + }); + return { success: false as const, status: 502, error: envelopeError }; + } + + const data: Record = isRecord(payload.data) ? payload.data : {}; + + // No task id and no query endpoint exist for this operation, so an + // unfinished generation cannot be polled — surface it instead of hanging. + if (numberValue(data.status) === STATUS_IN_PROGRESS) { + const pending = "MiniMax music generation is still in progress; retry the request"; + logMinimaxMusicCall({ + status: 502, + model: modelLabel, + provider, + duration: Date.now() - startTime, + error: pending, + }); + return { success: false as const, status: 502, error: pending }; + } + + const audio = stringValue(data.audio); + if (!audio) { + const errorMessage = "MiniMax music generation returned no audio"; + logMinimaxMusicCall({ + status: 502, + model: modelLabel, + provider, + duration: Date.now() - startTime, + error: errorMessage, + }); + return { success: false as const, status: 502, error: errorMessage }; + } + + const track = + upstreamBody.output_format === "hex" + ? { b64_json: hexAudioToBase64(audio), format: audioFormat } + : { url: audio, format: audioFormat }; + + logMinimaxMusicCall({ + status: 200, + model: modelLabel, + provider, + duration: Date.now() - startTime, + responseBody: { audio_count: 1 }, + }); + + return { + success: true as const, + data: { created: Math.floor(Date.now() / 1000), data: [track] }, + }; + } catch (err: unknown) { + const errorMessage = sanitizeErrorMessage(err) || "Music provider error"; + log?.error?.("MUSIC", `${provider} minimax-music error: ${errorMessage}`); + logMinimaxMusicCall({ + status: 502, + model: modelLabel, + provider, + duration: Date.now() - startTime, + error: errorMessage, + }); + return { success: false as const, status: 502, error: errorMessage }; + } +} diff --git a/open-sse/handlers/musicGeneration.ts b/open-sse/handlers/musicGeneration.ts index 92ee333c2e..89052b3765 100644 --- a/open-sse/handlers/musicGeneration.ts +++ b/open-sse/handlers/musicGeneration.ts @@ -33,6 +33,7 @@ import { } from "../utils/kieTask.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; import { handleFalMusicGeneration } from "./mediaGeneration/fal.ts"; +import { handleMinimaxMusicGeneration } from "./mediaGeneration/minimaxMusic.ts"; function normalizeKieSunoModel(model: string): string { const map: Record = { @@ -153,6 +154,17 @@ export async function handleMusicGeneration({ body, credentials, log }) { return handleUdioMusicGeneration({ model, provider, providerConfig, body, credentials, log }); } + if (providerConfig.format === "minimax-music") { + return handleMinimaxMusicGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, + }); + } + return { success: false, status: 400, diff --git a/tests/unit/minimax-music-generation.test.ts b/tests/unit/minimax-music-generation.test.ts new file mode 100644 index 0000000000..14ef8ef975 --- /dev/null +++ b/tests/unit/minimax-music-generation.test.ts @@ -0,0 +1,267 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-minimax-music-")); + +const { handleMusicGeneration } = await import("../../open-sse/handlers/musicGeneration.ts"); +const { MUSIC_PROVIDERS } = await import("../../open-sse/config/musicRegistry.ts"); + +const GLOBAL_ENDPOINT = MUSIC_PROVIDERS.minimax.baseUrl; +const REGIONAL_ENDPOINT = MUSIC_PROVIDERS.minimax.regionalBaseUrl as string; + +interface Captured { + url: string; + authorization: string; + contentType: string; + body: Record; +} + +/** Installs a fetch stub answering every call with `payload`, capturing the request. */ +function stubFetch(payload: unknown, status = 200) { + const captured: Captured[] = []; + const originalFetch = globalThis.fetch; + + globalThis.fetch = (async (url: string | URL | Request, options: RequestInit = {}) => { + const headers = new Headers(options.headers ?? {}); + captured.push({ + url: String(url), + authorization: headers.get("authorization") ?? "", + contentType: headers.get("content-type") ?? "", + body: JSON.parse(String(options.body ?? "{}")) as Record, + }); + return new Response(JSON.stringify(payload), { + status, + headers: { "content-type": "application/json" }, + }); + }) as typeof globalThis.fetch; + + return { + captured, + restore() { + globalThis.fetch = originalFetch; + }, + }; +} + +test("minimax is registered with the music models it serves and no query endpoint", () => { + const provider = MUSIC_PROVIDERS.minimax; + const modelIds = provider.models.map((model) => model.id); + + assert.equal(provider.format, "minimax-music"); + assert.equal(provider.authHeader, "bearer"); + assert.equal(provider.statusUrl, undefined); + assert.ok(REGIONAL_ENDPOINT, "a regional endpoint must be declared"); + assert.notEqual(new URL(REGIONAL_ENDPOINT).host, new URL(GLOBAL_ENDPOINT).host); + assert.deepEqual(modelIds, [ + "music-3.0", + "music-2.6", + "music-3.0-free", + "music-2.6-free", + "music-cover", + "music-cover-free", + ]); +}); + +test("handleMusicGeneration dispatches minimax-music and normalizes the audio URL", async () => { + const stub = stubFetch({ + data: { status: 2, audio: "https://example.com/minimax-music.mp3" }, + base_resp: { status_code: 0, status_msg: "success" }, + }); + + try { + const result = await handleMusicGeneration({ + body: { + model: "minimax/music-3.0", + prompt: "warm lo-fi guitar loop", + lyrics: "##first line\nsecond line##", + is_instrumental: false, + lyrics_optimizer: true, + audio_setting: { format: "wav", sample_rate: 44100, bitrate: 256000, bogus: "drop-me" }, + aigc_watermark: true, + }, + credentials: { apiKey: "minimax-key" }, + log: null, + }); + + assert.equal(stub.captured.length, 1); + const request = stub.captured[0]; + assert.equal(request.url, GLOBAL_ENDPOINT); + assert.equal(request.authorization, "Bearer minimax-key"); + assert.equal(request.contentType, "application/json"); + assert.equal(request.body.model, "music-3.0"); + assert.equal(request.body.prompt, "warm lo-fi guitar loop"); + assert.equal(request.body.lyrics, "##first line\nsecond line##"); + assert.equal(request.body.stream, false); + assert.equal(request.body.output_format, "url"); + assert.equal(request.body.is_instrumental, false); + assert.equal(request.body.lyrics_optimizer, true); + assert.deepEqual(request.body.audio_setting, { + sample_rate: 44100, + bitrate: 256000, + format: "wav", + }); + // The watermark field only exists on the regional endpoint. + assert.ok(!("aigc_watermark" in request.body)); + + assert.equal(result.success, true); + assert.deepEqual(result.data.data, [ + { url: "https://example.com/minimax-music.mp3", format: "wav" }, + ]); + } finally { + stub.restore(); + } +}); + +test("minimax-music forwards cover inputs and honors the hex output format", async () => { + const stub = stubFetch({ + data: { status: 2, audio: "48656c6c6f" }, + base_resp: { status_code: 0 }, + }); + + try { + const result = await handleMusicGeneration({ + body: { + model: "minimax/music-cover", + prompt: "cover this take", + output_format: "HEX", + audio_url: "https://example.com/reference.mp3", + cover_feature_id: "feature-1", + }, + credentials: { accessToken: "minimax-token" }, + log: null, + }); + + const request = stub.captured[0]; + assert.equal(request.body.model, "music-cover"); + assert.equal(request.body.output_format, "hex"); + assert.equal(request.body.audio_url, "https://example.com/reference.mp3"); + assert.equal(request.body.cover_feature_id, "feature-1"); + + assert.equal(result.success, true); + assert.deepEqual(result.data.data, [ + { b64_json: Buffer.from("Hello").toString("base64"), format: "mp3" }, + ]); + } finally { + stub.restore(); + } +}); + +test("minimax-music targets the regional endpoint via the connection base URL", async () => { + const stub = stubFetch({ + data: { status: 2, audio: "https://example.com/regional.mp3" }, + base_resp: { status_code: 0 }, + }); + + try { + const result = await handleMusicGeneration({ + body: { model: "minimax/music-2.6", prompt: "guzheng ballad", aigc_watermark: true }, + credentials: { + apiKey: "minimax-key", + providerSpecificData: { baseUrl: REGIONAL_ENDPOINT }, + }, + log: null, + }); + + const request = stub.captured[0]; + assert.equal(request.url, REGIONAL_ENDPOINT); + assert.equal(request.body.aigc_watermark, true); + assert.equal(result.success, true); + } finally { + stub.restore(); + } +}); + +test("minimax-music surfaces base_resp failures returned with HTTP 200", async () => { + const stub = stubFetch({ base_resp: { status_code: 1004, status_msg: "invalid api key" } }); + + try { + const logged: string[] = []; + const result = await handleMusicGeneration({ + body: { model: "minimax/music-3.0", prompt: "x" }, + credentials: { apiKey: "minimax-key" }, + log: { info: () => {}, error: (_scope: string, message: string) => logged.push(message) }, + }); + + assert.equal(result.success, false); + assert.equal(result.status, 502); + assert.equal(result.error, "invalid api key"); + assert.equal(logged.length, 1); + } finally { + stub.restore(); + } +}); + +test("minimax-music reports an unfinished generation instead of polling", async () => { + const stub = stubFetch({ data: { status: 1 }, base_resp: { status_code: 0 } }); + + try { + const result = await handleMusicGeneration({ + body: { model: "minimax/music-3.0-free", prompt: "x" }, + credentials: { apiKey: "minimax-key" }, + log: null, + }); + + assert.equal(result.success, false); + assert.equal(result.status, 502); + assert.match(result.error, /still in progress/); + } finally { + stub.restore(); + } +}); + +test("minimax-music rejects a completed response that carries no audio", async () => { + const stub = stubFetch({ data: { status: 2 }, base_resp: { status_code: 0 } }); + + try { + const result = await handleMusicGeneration({ + body: { model: "minimax/music-3.0", prompt: "x" }, + credentials: { apiKey: "minimax-key" }, + log: null, + }); + + assert.equal(result.success, false); + assert.equal(result.status, 502); + assert.match(result.error, /returned no audio/); + } finally { + stub.restore(); + } +}); + +test("minimax-music propagates upstream HTTP failures", async () => { + const stub = stubFetch({ base_resp: { status_code: 2013, status_msg: "invalid params" } }, 400); + + try { + const result = await handleMusicGeneration({ + body: { model: "minimax/music-3.0", prompt: "x" }, + credentials: { apiKey: "minimax-key" }, + log: null, + }); + + assert.equal(result.success, false); + assert.equal(result.status, 400); + assert.equal(result.error, "invalid params"); + } finally { + stub.restore(); + } +}); + +test("minimax-music refuses to call upstream without a credential", async () => { + const stub = stubFetch({}); + + try { + const result = await handleMusicGeneration({ + body: { model: "minimax/music-3.0", prompt: "x" }, + credentials: null, + log: null, + }); + + assert.equal(result.success, false); + assert.equal(result.status, 401); + assert.equal(stub.captured.length, 0); + } finally { + stub.restore(); + } +});