diff --git a/extensions/fal/music-generation-provider.test.ts b/extensions/fal/music-generation-provider.test.ts index c9914de38f66..b014d9d6c5aa 100644 --- a/extensions/fal/music-generation-provider.test.ts +++ b/extensions/fal/music-generation-provider.test.ts @@ -45,6 +45,18 @@ function postRequest(): Record { return request as Record; } +function streamedAudioResponse(bytes: string): Response { + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(bytes)); + controller.close(); + }, + }), + { headers: { "content-type": "audio/mpeg" } }, + ); +} + describe("fal music generation provider", () => { afterEach(() => { assertOkOrThrowHttpErrorMock.mockClear(); @@ -111,6 +123,33 @@ describe("fal music generation provider", () => { expect(result.metadata?.audioUrl).toBe("https://v3b.fal.media/files/b/kangaroo/out.mp3"); }); + it("rejects generated music downloads that exceed the configured media cap", async () => { + postJsonRequestMock.mockResolvedValue({ + response: { + json: async () => ({ + audio: { + url: "https://v3b.fal.media/files/b/out.mp3", + content_type: "audio/mpeg", + }, + }), + }, + release: vi.fn(async () => {}), + }); + vi.stubGlobal( + "fetch", + vi.fn(async () => streamedAudioResponse("too-large")), + ); + + await expect( + buildFalMusicGenerationProvider().generateMusic({ + provider: "fal", + model: "fal-ai/minimax-music/v2.6", + prompt: "short track", + cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } }, + }), + ).rejects.toThrow("fal generated music download exceeds 1 bytes"); + }); + it("rejects MiniMax lyrics requests that also ask for instrumental output", async () => { await expect( buildFalMusicGenerationProvider().generateMusic({ diff --git a/extensions/fal/music-generation-provider.ts b/extensions/fal/music-generation-provider.ts index bb53fc639c55..de49144c6ed3 100644 --- a/extensions/fal/music-generation-provider.ts +++ b/extensions/fal/music-generation-provider.ts @@ -13,6 +13,7 @@ const DEFAULT_FAL_MUSIC_MODEL = "fal-ai/minimax-music/v2.6"; const FAL_ACE_STEP_MODEL = "fal-ai/ace-step/prompt-to-audio"; const FAL_STABLE_AUDIO_MODEL = "fal-ai/stable-audio-25/text-to-audio"; const DEFAULT_TIMEOUT_MS = 180_000; +const DEFAULT_GENERATED_MUSIC_MAX_BYTES = 16 * 1024 * 1024; const FAL_MUSIC_MODELS = [ DEFAULT_FAL_MUSIC_MODEL, @@ -24,6 +25,14 @@ function resolveFalMusicModel(model: string | undefined): string { return normalizeOptionalString(model) ?? DEFAULT_FAL_MUSIC_MODEL; } +function resolveGeneratedMusicMaxBytes(req: MusicGenerationRequest): number { + const configured = req.cfg.agents?.defaults?.mediaMaxMb; + if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) { + return Math.floor(configured * 1024 * 1024); + } + return DEFAULT_GENERATED_MUSIC_MAX_BYTES; +} + function buildFalMinimaxBody(req: MusicGenerationRequest): Record { const lyrics = normalizeOptionalString(req.lyrics); if (lyrics && req.instrumental === true) { @@ -162,6 +171,7 @@ export function buildFalMusicGenerationProvider(): MusicGenerationProvider { fetchFn: fetch, provider: "fal", requestFailedMessage: "fal generated music download failed", + maxBytes: resolveGeneratedMusicMaxBytes(req), }); const lyrics = typeof payload === "object" && payload && !Array.isArray(payload) diff --git a/extensions/minimax/music-generation-provider.test.ts b/extensions/minimax/music-generation-provider.test.ts index e08e116d72d9..e87b0ae4ee71 100644 --- a/extensions/minimax/music-generation-provider.test.ts +++ b/extensions/minimax/music-generation-provider.test.ts @@ -49,6 +49,18 @@ function mockCallArg(mock: { mock: { calls: unknown[][] } }, index = 0): Record< return call[0] as Record; } +function streamedAudioResponse(bytes: string): Response { + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(bytes)); + controller.close(); + }, + }), + { headers: { "content-type": "audio/mpeg" } }, + ); +} + describe("minimax music generation provider", () => { it("declares explicit mode capabilities", () => { expectExplicitMusicGenerationCapabilities(buildMinimaxMusicGenerationProvider()); @@ -161,6 +173,58 @@ describe("minimax music generation provider", () => { expect(result.tracks[0]?.buffer).toEqual(terminalAudio); }); + it("rejects streamed generated music that exceeds the configured media cap", async () => { + postJsonRequestMock.mockResolvedValue({ + response: new Response( + `data: ${JSON.stringify({ + data: { status: 2, audio: Buffer.from("too-large").toString("hex") }, + base_resp: { status_code: 0 }, + })}`, + { + headers: { "content-type": "text/event-stream" }, + }, + ), + release: vi.fn(async () => {}), + }); + + const provider = buildMinimaxMusicGenerationProvider(); + await expect( + provider.generateMusic({ + provider: "minimax", + model: "music-2.6", + prompt: "short track", + cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } }, + }), + ).rejects.toThrow("MiniMax generated music download exceeds 1 bytes"); + }); + + it("rejects inline generated music that exceeds the configured media cap before decoding", async () => { + postJsonRequestMock.mockResolvedValue({ + response: new Response( + JSON.stringify({ + data: { + audio: Buffer.from("too-large").toString("hex"), + }, + base_resp: { status_code: 0 }, + }), + { + headers: { "content-type": "application/json" }, + }, + ), + release: vi.fn(async () => {}), + }); + + const provider = buildMinimaxMusicGenerationProvider(); + await expect( + provider.generateMusic({ + provider: "minimax", + model: "music-2.6", + prompt: "short track", + cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } }, + }), + ).rejects.toThrow("MiniMax generated music download exceeds 1 bytes"); + }); + it("downloads tracks when url output is returned in data.audio", async () => { mockMusicGenerationResponse({ task_id: "task-url", @@ -192,6 +256,32 @@ describe("minimax music generation provider", () => { expect(result.metadata?.audioUrl).toBe("https://example.com/url-audio.mp3"); }); + it("rejects generated music downloads that exceed the configured media cap", async () => { + postJsonRequestMock.mockResolvedValue({ + response: new Response( + JSON.stringify({ + data: { + audio: "https://example.com/too-large.mp3", + }, + base_resp: { status_code: 0 }, + }), + { headers: { "content-type": "application/json" } }, + ), + release: vi.fn(async () => {}), + }); + fetchWithTimeoutMock.mockResolvedValueOnce(streamedAudioResponse("too-large")); + + const provider = buildMinimaxMusicGenerationProvider(); + await expect( + provider.generateMusic({ + provider: "minimax", + model: "music-2.6", + prompt: "short track", + cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } }, + }), + ).rejects.toThrow("MiniMax generated music download exceeds 1 bytes"); + }); + it("honors explicit long caller timeouts for request and download fallbacks", async () => { mockMusicGenerationResponse({ data: { diff --git a/extensions/minimax/music-generation-provider.ts b/extensions/minimax/music-generation-provider.ts index b55f10c76508..7e8ad73c34cb 100644 --- a/extensions/minimax/music-generation-provider.ts +++ b/extensions/minimax/music-generation-provider.ts @@ -2,6 +2,7 @@ import { extensionForMime } from "openclaw/plugin-sdk/media-mime"; import type { GeneratedMusicAsset, MusicGenerationProvider, + MusicGenerationRequest, } from "openclaw/plugin-sdk/music-generation"; import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth"; import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime"; @@ -14,12 +15,16 @@ import { resolveProviderHttpRequestConfig, type ProviderOperationDeadline, } from "openclaw/plugin-sdk/provider-http"; +import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; const DEFAULT_MINIMAX_MUSIC_BASE_URL = "https://api.minimax.io"; const DEFAULT_MINIMAX_MUSIC_MODEL = "music-2.6"; const DEFAULT_TIMEOUT_MS = 120_000; const DEFAULT_OPERATION_TIMEOUT_MS = 300_000; +const DEFAULT_GENERATED_MUSIC_MAX_BYTES = 16 * 1024 * 1024; +const STREAM_ENVELOPE_MAX_BYTES_MULTIPLIER = 5; +const STREAM_ENVELOPE_OVERHEAD_BYTES = 64 * 1024; type MinimaxBaseResp = { status_code?: number; @@ -71,11 +76,17 @@ function assertMinimaxBaseResp(baseResp: MinimaxBaseResp | undefined, context: s ); } -function decodePossibleBinary(data: string): Buffer { +function decodePossibleBinaryWithLimit(data: string, maxBytes: number): Buffer { const trimmed = data.trim(); if (/^[0-9a-f]+$/iu.test(trimmed) && trimmed.length % 2 === 0) { + if (trimmed.length / 2 > maxBytes) { + throw createGeneratedMusicTooLargeError(maxBytes); + } return Buffer.from(trimmed, "hex"); } + if (Buffer.byteLength(trimmed, "base64") > maxBytes) { + throw createGeneratedMusicTooLargeError(maxBytes); + } return Buffer.from(trimmed, "base64"); } @@ -95,10 +106,19 @@ function isLikelyRemoteUrl(value: string | undefined): boolean { return Boolean(trimmed && /^https?:\/\//iu.test(trimmed)); } +function resolveGeneratedMusicMaxBytes(req: MusicGenerationRequest): number { + const configured = req.cfg.agents?.defaults?.mediaMaxMb; + if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) { + return Math.floor(configured * 1024 * 1024); + } + return DEFAULT_GENERATED_MUSIC_MAX_BYTES; +} + async function downloadTrackFromUrl(params: { url: string; timeoutMs?: number; fetchFn: typeof fetch; + maxBytes: number; }): Promise { const response = await fetchProviderDownloadResponse({ url: params.url, @@ -111,7 +131,10 @@ async function downloadTrackFromUrl(params: { const mimeType = normalizeOptionalString(response.headers.get("content-type")) ?? "audio/mpeg"; const ext = extensionForMime(mimeType)?.replace(/^\./u, "") || "mp3"; return { - buffer: Buffer.from(await response.arrayBuffer()), + buffer: await readResponseWithLimit(response, params.maxBytes, { + onOverflow: ({ maxBytes }) => + new Error(`MiniMax generated music download exceeds ${maxBytes} bytes`), + }), mimeType, fileName: `track-1.${ext}`, }; @@ -130,9 +153,21 @@ function resolveBodyReadTimeoutMs(deadline: ProviderOperationDeadline): number { }); } +function createGeneratedMusicTooLargeError(maxBytes: number): Error { + return new Error(`MiniMax generated music download exceeds ${maxBytes} bytes`); +} + +function resolveStreamEnvelopeMaxBytes(maxBytes: number): number { + return Math.max( + STREAM_ENVELOPE_OVERHEAD_BYTES, + maxBytes * STREAM_ENVELOPE_MAX_BYTES_MULTIPLIER + STREAM_ENVELOPE_OVERHEAD_BYTES, + ); +} + async function readResponseBufferWithDeadline( response: Response, deadline: ProviderOperationDeadline, + maxBytes: number, ): Promise { const body = response.body; if (!body) { @@ -157,8 +192,18 @@ async function readResponseBufferWithDeadline( if (!result.value || result.value.length === 0) { continue; } + const nextTotalBytes = totalBytes + result.value.byteLength; + if (nextTotalBytes > maxBytes) { + const error = createGeneratedMusicTooLargeError(maxBytes); + try { + await reader.cancel(error); + } catch { + // Preserve the size-limit failure that caused cancellation. + } + throw error; + } chunks.push(result.value); - totalBytes += result.value.byteLength; + totalBytes = nextTotalBytes; } catch (error) { try { await reader.cancel(error); @@ -188,18 +233,26 @@ async function readResponseBufferWithDeadline( async function readStreamingTrack( response: Response, deadline: ProviderOperationDeadline, + maxBytes: number, ): Promise { const contentType = normalizeOptionalString(response.headers.get("content-type")) ?? ""; if (contentType.toLowerCase().startsWith("audio/")) { const ext = extensionForMime(contentType)?.replace(/^\./u, "") || "mp3"; return { - buffer: await readResponseBufferWithDeadline(response, deadline), + buffer: await readResponseBufferWithDeadline(response, deadline, maxBytes), mimeType: contentType, fileName: `track-1.${ext}`, }; } const chunks: Buffer[] = []; - const text = new TextDecoder().decode(await readResponseBufferWithDeadline(response, deadline)); + let decodedBytes = 0; + const text = new TextDecoder().decode( + await readResponseBufferWithDeadline( + response, + deadline, + resolveStreamEnvelopeMaxBytes(maxBytes), + ), + ); for (const rawLine of text.split(/\r?\n/u)) { const line = rawLine.trim(); if (!line.startsWith("data:")) { @@ -216,7 +269,13 @@ async function readStreamingTrack( if (String(frame.data?.status ?? "") === "2" && chunks.length > 0) { continue; } - chunks.push(decodePossibleBinary(audio)); + const chunk = decodePossibleBinaryWithLimit(audio, maxBytes - decodedBytes); + const nextDecodedBytes = decodedBytes + chunk.byteLength; + if (nextDecodedBytes > maxBytes) { + throw createGeneratedMusicTooLargeError(maxBytes); + } + chunks.push(chunk); + decodedBytes = nextDecodedBytes; } } const buffer = Buffer.concat(chunks); @@ -341,10 +400,19 @@ function buildMinimaxMusicProvider(providerId: string): MusicGenerationProvider await assertOkOrThrowHttpError(res, "MiniMax music generation failed"); const contentType = normalizeOptionalString(res.headers.get("content-type")) ?? ""; const lowerContentType = contentType.toLowerCase(); + const maxGeneratedMusicBytes = resolveGeneratedMusicMaxBytes(req); const payload = lowerContentType.includes("text/event-stream") || lowerContentType.startsWith("audio/") ? null - : ((await res.clone().json()) as MinimaxMusicCreateResponse); + : (JSON.parse( + new TextDecoder().decode( + await readResponseBufferWithDeadline( + res.clone(), + deadline, + resolveStreamEnvelopeMaxBytes(maxGeneratedMusicBytes), + ), + ), + ) as MinimaxMusicCreateResponse); if (payload) { assertMinimaxBaseResp(payload.base_resp, "MiniMax music generation failed"); } @@ -366,14 +434,18 @@ function buildMinimaxMusicProvider(providerId: string): MusicGenerationProvider defaultTimeoutMs: req.timeoutMs ?? DEFAULT_TIMEOUT_MS, }), fetchFn, + maxBytes: resolveGeneratedMusicMaxBytes(req), }) : inlineAudio - ? { - buffer: decodePossibleBinary(inlineAudio), - mimeType: "audio/mpeg", - fileName: "track-1.mp3", - } - : await readStreamingTrack(res, deadline); + ? (() => { + const buffer = decodePossibleBinaryWithLimit(inlineAudio, maxGeneratedMusicBytes); + return { + buffer, + mimeType: "audio/mpeg", + fileName: "track-1.mp3", + }; + })() + : await readStreamingTrack(res, deadline, maxGeneratedMusicBytes); if (!track) { throw new Error("MiniMax music generation response missing audio output"); } diff --git a/src/music-generation/provider-assets.ts b/src/music-generation/provider-assets.ts index a648a07d4e75..6f047995452c 100644 --- a/src/music-generation/provider-assets.ts +++ b/src/music-generation/provider-assets.ts @@ -1,5 +1,7 @@ import { fetchProviderDownloadResponse } from "../media-understanding/shared.js"; +import { maxBytesForKind } from "../media/constants.js"; import { extensionForMime } from "../media/mime.js"; +import { readResponseWithLimit } from "../media/read-response-with-limit.js"; import { isRecord } from "../shared/record-coerce.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import type { GeneratedMusicAsset } from "./types.js"; @@ -82,6 +84,7 @@ export async function downloadGeneratedMusicAsset(params: { provider: string; requestFailedMessage: string; index?: number; + maxBytes?: number; }): Promise { const response = await fetchProviderDownloadResponse({ url: params.candidate.url, @@ -96,8 +99,12 @@ export async function downloadGeneratedMusicAsset(params: { normalizeSpecificAudioMimeType(params.candidate.mimeType) ?? "audio/mpeg"; const ext = extensionForMime(mimeType)?.replace(/^\./u, "") || "mp3"; + const maxBytes = params.maxBytes ?? maxBytesForKind("audio"); return { - buffer: Buffer.from(await response.arrayBuffer()), + buffer: await readResponseWithLimit(response, maxBytes, { + onOverflow: ({ maxBytes }) => + new Error(`${params.provider} generated music download exceeds ${maxBytes} bytes`), + }), mimeType, fileName: params.candidate.fileName ?? `track-${(params.index ?? 0) + 1}.${ext}`, metadata: {