fix(sse): add the missing minimax-music dispatch to music generation (#10650)

Obrigado — bug real: MUSIC_PROVIDERS.minimax declara format "minimax-music" e seus modelos são publicados pelo catálogo, mas handleMusicGeneration nunca teve um branch para esse format — todo request minimax/* caía no guard final com "Unsupported music format", modelos anunciados mas inalcançáveis. Handler completo cobrindo os dois output formats (url/hex), envelope base_resp, endpoint regional, e guarda local de credencial ausente.

Validação (worktree própria a partir de origin/release/v3.8.50, merge limpo, 0 conflitos):
- typecheck:core limpo, complexity/cognitive-complexity dentro do baseline
- tests/unit/minimax-music-generation.test.ts — 9/9 passando
This commit is contained in:
Octopus
2026-08-21 08:37:54 +08:00
committed by GitHub
parent 1f04e73a1c
commit 3fed9e837a
5 changed files with 648 additions and 1 deletions

View File

@@ -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).

View File

@@ -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<string, MusicProvider> = {
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: {

View File

@@ -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<string, unknown>;
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<string, unknown> {
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<string, unknown> | undefined {
const provided: Record<string, unknown> = isRecord(body.audio_setting) ? body.audio_setting : {};
const setting: Record<string, unknown> = {};
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<string, unknown> = 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<string, unknown> {
const request: Record<string, unknown> = {
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<Record<string, unknown>> {
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, unknown>): string | undefined {
const baseResp: Record<string, unknown> = 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<string, unknown> = 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 };
}
}

View File

@@ -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<string, string> = {
@@ -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,

View File

@@ -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<string, unknown>;
}
/** 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<string, unknown>,
});
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();
}
});