mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-22 07:02:16 +03:00
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:
1
changelog.d/fixes/minimax-music-generation-dispatch.md
Normal file
1
changelog.d/fixes/minimax-music-generation-dispatch.md
Normal 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).
|
||||
@@ -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: {
|
||||
|
||||
358
open-sse/handlers/mediaGeneration/minimaxMusic.ts
Normal file
358
open-sse/handlers/mediaGeneration/minimaxMusic.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
267
tests/unit/minimax-music-generation.test.ts
Normal file
267
tests/unit/minimax-music-generation.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user