feat: add fal and OpenRouter music generation (#82789)

* feat: add fal and OpenRouter music generation

* fix: repair music generation CI gates

* chore: refresh proof gate
This commit is contained in:
Peter Steinberger
2026-05-17 02:05:22 +01:00
committed by GitHub
parent 562d460d75
commit f453904165
54 changed files with 1535 additions and 87 deletions
+3 -1
View File
@@ -1,5 +1,6 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { buildFalImageGenerationProvider } from "./image-generation-provider.js";
import { buildFalMusicGenerationProvider } from "./music-generation-provider.js";
import { createFalProvider } from "./provider-registration.js";
import { buildFalVideoGenerationProvider } from "./video-generation-provider.js";
@@ -8,10 +9,11 @@ const PROVIDER_ID = "fal";
export default definePluginEntry({
id: PROVIDER_ID,
name: "fal Provider",
description: "Bundled fal image and video generation provider",
description: "Bundled fal image, video, and music generation provider",
register(api) {
api.registerProvider(createFalProvider());
api.registerImageGenerationProvider(buildFalImageGenerationProvider());
api.registerMusicGenerationProvider(buildFalMusicGenerationProvider());
api.registerVideoGenerationProvider(buildFalVideoGenerationProvider());
},
});
@@ -0,0 +1,200 @@
import { expectExplicitMusicGenerationCapabilities } from "openclaw/plugin-sdk/provider-test-contracts";
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildFalMusicGenerationProvider } from "./music-generation-provider.js";
const {
assertOkOrThrowHttpErrorMock,
postJsonRequestMock,
resolveApiKeyForProviderMock,
resolveProviderHttpRequestConfigMock,
} = vi.hoisted(() => ({
assertOkOrThrowHttpErrorMock: vi.fn(async () => {}),
postJsonRequestMock: vi.fn(),
resolveApiKeyForProviderMock: vi.fn(async () => ({
apiKey: "fal-key",
source: "env",
mode: "api-key",
})),
resolveProviderHttpRequestConfigMock: vi.fn((params: Record<string, unknown>) => ({
baseUrl: params.baseUrl ?? params.defaultBaseUrl,
allowPrivateNetwork: false,
headers: new Headers(params.defaultHeaders as HeadersInit | undefined),
dispatcherPolicy: undefined,
})),
}));
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
}));
vi.mock("openclaw/plugin-sdk/provider-http", async (importOriginal) => {
const original = await importOriginal<typeof import("openclaw/plugin-sdk/provider-http")>();
return {
...original,
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
postJsonRequest: postJsonRequestMock,
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
};
});
function postRequest(): Record<string, unknown> {
const request = postJsonRequestMock.mock.calls[0]?.[0];
if (!request || typeof request !== "object" || Array.isArray(request)) {
throw new Error("expected fal music request");
}
return request as Record<string, unknown>;
}
describe("fal music generation provider", () => {
afterEach(() => {
assertOkOrThrowHttpErrorMock.mockClear();
postJsonRequestMock.mockReset();
resolveApiKeyForProviderMock.mockClear();
resolveProviderHttpRequestConfigMock.mockClear();
vi.unstubAllGlobals();
});
it("declares explicit mode capabilities", () => {
expectExplicitMusicGenerationCapabilities(buildFalMusicGenerationProvider());
});
it("submits MiniMax music through fal and downloads the generated track", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
audio: {
url: "https://v3b.fal.media/files/b/kangaroo/out.mp3",
content_type: "audio/mpeg",
file_name: "out.mp3",
},
}),
},
release: vi.fn(async () => {}),
});
const fetchMock = vi.fn(
async () =>
new Response(Buffer.from("mp3-bytes"), {
headers: { "content-type": "application/octet-stream" },
}),
);
vi.stubGlobal("fetch", fetchMock);
const result = await buildFalMusicGenerationProvider().generateMusic({
provider: "fal",
model: "",
prompt: "city pop chorus",
cfg: {},
lyrics: "[Verse]\nNeon rain",
durationSeconds: 42,
format: "mp3",
});
expect(postRequest().url).toBe("https://fal.run/fal-ai/minimax-music/v2.6");
expect(postRequest().body).toEqual({
prompt: "city pop chorus",
lyrics: "[Verse]\nNeon rain",
duration: 42,
audio_setting: {
sample_rate: 44100,
bitrate: 256000,
format: "mp3",
},
});
expect(fetchMock).toHaveBeenCalledWith(
"https://v3b.fal.media/files/b/kangaroo/out.mp3",
expect.objectContaining({ method: "GET" }),
);
expect(result.model).toBe("fal-ai/minimax-music/v2.6");
expect(result.tracks[0]?.mimeType).toBe("audio/mpeg");
expect(result.tracks[0]?.buffer).toEqual(Buffer.from("mp3-bytes"));
expect(result.tracks[0]?.fileName).toBe("out.mp3");
expect(result.metadata?.audioUrl).toBe("https://v3b.fal.media/files/b/kangaroo/out.mp3");
});
it("rejects MiniMax lyrics requests that also ask for instrumental output", async () => {
await expect(
buildFalMusicGenerationProvider().generateMusic({
provider: "fal",
model: "fal-ai/minimax-music/v2.6",
prompt: "city pop chorus",
cfg: {},
lyrics: "[Verse]\nNeon rain",
instrumental: true,
}),
).rejects.toThrow("fal MiniMax music generation cannot use lyrics when instrumental=true.");
expect(postJsonRequestMock).not.toHaveBeenCalled();
});
it("maps ACE-Step duration and instrumental controls", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
audio: { url: "https://example.com/out.wav", content_type: "audio/wav" },
seed: 42,
tags: "lofi, chill",
}),
},
release: vi.fn(async () => {}),
});
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(Buffer.from("wav-bytes"), {
headers: { "content-type": "audio/wav" },
}),
),
);
await buildFalMusicGenerationProvider().generateMusic({
provider: "fal",
model: "fal-ai/ace-step/prompt-to-audio",
prompt: "lofi beach loop",
cfg: {},
instrumental: true,
durationSeconds: 30,
});
expect(postRequest().url).toBe("https://fal.run/fal-ai/ace-step/prompt-to-audio");
expect(postRequest().body).toEqual({
prompt: "lofi beach loop",
instrumental: true,
duration: 30,
});
});
it("maps Stable Audio duration controls", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
audio: "https://example.com/stable.wav",
}),
},
release: vi.fn(async () => {}),
});
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(Buffer.from("wav-bytes"), {
headers: { "content-type": "audio/wav" },
}),
),
);
await buildFalMusicGenerationProvider().generateMusic({
provider: "fal",
model: "fal-ai/stable-audio-25/text-to-audio",
prompt: "orchestral hit",
cfg: {},
durationSeconds: 12,
});
expect(postRequest().url).toBe("https://fal.run/fal-ai/stable-audio-25/text-to-audio");
expect(postRequest().body).toEqual({
prompt: "orchestral hit",
seconds_total: 12,
});
});
});
+219
View File
@@ -0,0 +1,219 @@
import {
downloadGeneratedMusicAsset,
extractGeneratedMusicFileCandidates,
type MusicGenerationProvider,
type MusicGenerationRequest,
} from "openclaw/plugin-sdk/music-generation";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
assertOkOrThrowHttpError,
postJsonRequest,
resolveProviderHttpRequestConfig,
} from "openclaw/plugin-sdk/provider-http";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
const DEFAULT_FAL_BASE_URL = "https://fal.run";
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 FAL_MUSIC_MODELS = [
DEFAULT_FAL_MUSIC_MODEL,
FAL_ACE_STEP_MODEL,
FAL_STABLE_AUDIO_MODEL,
] as const;
function resolveFalMusicModel(model: string | undefined): string {
return normalizeOptionalString(model) ?? DEFAULT_FAL_MUSIC_MODEL;
}
function resolveFalMusicBaseUrl(req: MusicGenerationRequest): string | undefined {
return normalizeOptionalString(req.cfg?.models?.providers?.fal?.baseUrl);
}
function buildFalMinimaxBody(req: MusicGenerationRequest): Record<string, unknown> {
const lyrics = normalizeOptionalString(req.lyrics);
if (lyrics && req.instrumental === true) {
throw new Error("fal MiniMax music generation cannot use lyrics when instrumental=true.");
}
return {
prompt: req.prompt,
...(lyrics ? { lyrics } : {}),
...(req.instrumental === true ? { is_instrumental: true } : {}),
...(!lyrics && req.instrumental !== true ? { lyrics_optimizer: true } : {}),
...(typeof req.durationSeconds === "number" ? { duration: req.durationSeconds } : {}),
audio_setting: {
sample_rate: 44_100,
bitrate: 256_000,
format: req.format ?? "mp3",
},
};
}
function buildFalAceStepBody(req: MusicGenerationRequest): Record<string, unknown> {
if (normalizeOptionalString(req.lyrics)) {
throw new Error("fal ACE-Step music generation does not support explicit lyrics.");
}
return {
prompt: req.prompt,
...(req.instrumental === true ? { instrumental: true } : {}),
...(typeof req.durationSeconds === "number" ? { duration: req.durationSeconds } : {}),
};
}
function buildFalStableAudioBody(req: MusicGenerationRequest): Record<string, unknown> {
if (normalizeOptionalString(req.lyrics)) {
throw new Error("fal Stable Audio music generation does not support explicit lyrics.");
}
if (req.instrumental === true) {
throw new Error("fal Stable Audio music generation does not support instrumental mode.");
}
return {
prompt: req.prompt,
...(typeof req.durationSeconds === "number" ? { seconds_total: req.durationSeconds } : {}),
};
}
function buildFalMusicRequestBody(
req: MusicGenerationRequest,
model: string,
): Record<string, unknown> {
if (model === FAL_ACE_STEP_MODEL) {
return buildFalAceStepBody(req);
}
if (model === FAL_STABLE_AUDIO_MODEL) {
return buildFalStableAudioBody(req);
}
return buildFalMinimaxBody(req);
}
function resolveFalMusicMetadata(payload: unknown): Record<string, unknown> | undefined {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return undefined;
}
const metadata: Record<string, unknown> = {};
for (const key of ["seed", "tags"]) {
const value = (payload as Record<string, unknown>)[key];
if (value !== undefined && value !== null) {
metadata[key] = value;
}
}
return Object.keys(metadata).length > 0 ? metadata : undefined;
}
export function buildFalMusicGenerationProvider(): MusicGenerationProvider {
return {
id: "fal",
label: "fal",
defaultModel: DEFAULT_FAL_MUSIC_MODEL,
models: [...FAL_MUSIC_MODELS],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({
provider: "fal",
agentDir,
}),
capabilities: {
generate: {
maxTracks: 1,
maxDurationSeconds: 240,
supportsLyrics: true,
supportsLyricsByModel: {
[FAL_ACE_STEP_MODEL]: false,
[FAL_STABLE_AUDIO_MODEL]: false,
},
supportsInstrumental: true,
supportsInstrumentalByModel: {
[FAL_STABLE_AUDIO_MODEL]: false,
},
supportsDuration: true,
supportsFormat: true,
supportedFormats: ["mp3", "wav"],
supportedFormatsByModel: {
[DEFAULT_FAL_MUSIC_MODEL]: ["mp3"],
[FAL_ACE_STEP_MODEL]: ["wav"],
[FAL_STABLE_AUDIO_MODEL]: ["wav"],
},
},
edit: {
enabled: false,
},
},
async generateMusic(req) {
if ((req.inputImages?.length ?? 0) > 0) {
throw new Error("fal music generation does not support image reference inputs.");
}
const auth = await resolveApiKeyForProvider({
provider: "fal",
cfg: req.cfg,
agentDir: req.agentDir,
store: req.authStore,
});
if (!auth.apiKey) {
throw new Error("fal API key missing");
}
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
resolveProviderHttpRequestConfig({
baseUrl: resolveFalMusicBaseUrl(req),
defaultBaseUrl: DEFAULT_FAL_BASE_URL,
allowPrivateNetwork: false,
defaultHeaders: {
Authorization: `Key ${auth.apiKey}`,
"Content-Type": "application/json",
},
provider: "fal",
capability: "audio",
transport: "http",
});
const model = resolveFalMusicModel(req.model);
const { response, release } = await postJsonRequest({
url: `${baseUrl}/${model}`,
headers,
body: buildFalMusicRequestBody(req, model),
timeoutMs: req.timeoutMs ?? DEFAULT_TIMEOUT_MS,
fetchFn: fetch,
allowPrivateNetwork,
dispatcherPolicy,
});
try {
await assertOkOrThrowHttpError(response, "fal music generation failed");
const payload = await response.json();
const [candidate] = extractGeneratedMusicFileCandidates(payload);
if (!candidate) {
throw new Error("fal music generation response missing audio output");
}
const track = await downloadGeneratedMusicAsset({
candidate,
timeoutMs: req.timeoutMs ?? DEFAULT_TIMEOUT_MS,
fetchFn: fetch,
provider: "fal",
requestFailedMessage: "fal generated music download failed",
});
const lyrics =
typeof payload === "object" && payload && !Array.isArray(payload)
? normalizeOptionalString((payload as Record<string, unknown>).lyrics)
: undefined;
return {
tracks: [track],
model,
...(lyrics ? { lyrics: [lyrics] } : {}),
metadata: {
...resolveFalMusicMetadata(payload),
...(track.metadata?.url ? { audioUrl: track.metadata.url } : {}),
instrumental: req.instrumental === true,
...(req.format ? { requestedFormat: req.format } : {}),
...(typeof req.durationSeconds === "number"
? { requestedDurationSeconds: req.durationSeconds }
: {}),
},
};
} finally {
await release();
}
},
};
}
+3 -2
View File
@@ -16,8 +16,8 @@
"choiceLabel": "fal API key",
"groupId": "fal",
"groupLabel": "fal",
"groupHint": "Image and video generation",
"onboardingScopes": ["image-generation"],
"groupHint": "Image, video, and music generation",
"onboardingScopes": ["image-generation", "music-generation"],
"optionKey": "falApiKey",
"cliFlag": "--fal-api-key",
"cliOption": "--fal-api-key <key>",
@@ -26,6 +26,7 @@
],
"contracts": {
"imageGenerationProviders": ["fal"],
"musicGenerationProviders": ["fal"],
"videoGenerationProviders": ["fal"]
},
"configSchema": {
@@ -4,6 +4,7 @@ describePluginRegistrationContract({
pluginId: "fal",
providerIds: ["fal"],
imageGenerationProviderIds: ["fal"],
musicGenerationProviderIds: ["fal"],
videoGenerationProviderIds: ["fal"],
requireGenerateImage: true,
requireGenerateVideo: true,
+4 -4
View File
@@ -14,16 +14,16 @@ export function createFalProvider(): ProviderPlugin {
id: "api-key",
kind: "api_key",
label: "fal API key",
hint: "Image and video generation API key",
hint: "Image, video, and music generation API key",
run: async () => ({ profiles: [], defaultModel: FAL_DEFAULT_IMAGE_MODEL_REF }),
wizard: {
choiceId: "fal-api-key",
choiceLabel: "fal API key",
choiceHint: "Image and video generation API key",
choiceHint: "Image, video, and music generation API key",
groupId: "fal",
groupLabel: "fal",
groupHint: "Image and video generation",
onboardingScopes: ["image-generation"],
groupHint: "Image, video, and music generation",
onboardingScopes: ["image-generation", "music-generation"],
},
},
],
+4 -4
View File
@@ -15,7 +15,7 @@ export function createFalProvider(): ProviderPlugin {
providerId: PROVIDER_ID,
methodId: "api-key",
label: "fal API key",
hint: "Image and video generation API key",
hint: "Image, video, and music generation API key",
optionKey: "falApiKey",
flagName: "--fal-api-key",
envVar: "FAL_KEY",
@@ -26,11 +26,11 @@ export function createFalProvider(): ProviderPlugin {
wizard: {
choiceId: "fal-api-key",
choiceLabel: "fal API key",
choiceHint: "Image and video generation API key",
choiceHint: "Image, video, and music generation API key",
groupId: "fal",
groupLabel: "fal",
groupHint: "Image and video generation",
onboardingScopes: ["image-generation"],
groupHint: "Image, video, and music generation",
onboardingScopes: ["image-generation", "music-generation"],
},
}),
],
+1
View File
@@ -1,2 +1,3 @@
export { buildFalImageGenerationProvider } from "./image-generation-provider.js";
export { buildFalMusicGenerationProvider } from "./music-generation-provider.js";
export { buildFalVideoGenerationProvider } from "./video-generation-provider.js";
@@ -30,8 +30,10 @@ import {
resolveLiveMusicAuthStore,
} from "openclaw/plugin-sdk/test-env";
import { describe, expect, it } from "vitest";
import falPlugin from "./fal/index.js";
import googlePlugin from "./google/index.js";
import minimaxPlugin from "./minimax/index.js";
import openrouterPlugin from "./openrouter/index.js";
import { maybeLoadShellEnvForGenerationProviders } from "./test-support/generation-live-test-helpers.js";
const LIVE = isLiveTestEnabled();
@@ -49,6 +51,12 @@ type LiveProviderCase = {
};
const CASES: LiveProviderCase[] = [
{
plugin: falPlugin,
pluginId: "fal",
pluginName: "fal Provider",
providerId: "fal",
},
{
plugin: googlePlugin,
pluginId: "google",
@@ -61,6 +69,12 @@ const CASES: LiveProviderCase[] = [
pluginName: "MiniMax Provider",
providerId: "minimax",
},
{
plugin: openrouterPlugin,
pluginId: "openrouter",
pluginName: "OpenRouter Provider",
providerId: "openrouter",
},
]
.filter((entry) => (providerFilter ? providerFilter.has(entry.providerId) : true))
.toSorted((left, right) => left.providerId.localeCompare(right.providerId));
@@ -130,7 +144,7 @@ function resolveLiveLyrics(providerId: string): string | undefined {
function resolveLiveMusicSkipReason(providerId: string, error: unknown): string | null {
const message = error instanceof Error ? error.message : String(error);
if (
providerId === "google" &&
(providerId === "google" || providerId === "openrouter") &&
message.toLowerCase().includes("music generation response missing audio data")
) {
return "transient no-audio response";
+1
View File
@@ -1,4 +1,5 @@
export { buildOpenRouterImageGenerationProvider } from "./image-generation-provider.js";
export { buildOpenRouterMusicGenerationProvider } from "./music-generation-provider.js";
export {
buildOpenrouterProvider,
isOpenRouterProxyReasoningUnsupportedModel,
+13 -6
View File
@@ -16,12 +16,18 @@ import { resolveThinkingProfile } from "./provider-policy-api.js";
describe("openrouter provider hooks", () => {
it("registers OpenRouter speech alongside model, media, and catalog providers", async () => {
const { providers, speechProviders, mediaProviders, imageProviders, videoProviders } =
await registerProviderPlugin({
plugin: openrouterPlugin,
id: "openrouter",
name: "OpenRouter Provider",
});
const {
providers,
speechProviders,
mediaProviders,
imageProviders,
musicProviders,
videoProviders,
} = await registerProviderPlugin({
plugin: openrouterPlugin,
id: "openrouter",
name: "OpenRouter Provider",
});
const modelCatalogProvider = expectUnifiedModelCatalogProviderRegistration({
plugin: openrouterPlugin,
pluginId: "openrouter",
@@ -34,6 +40,7 @@ describe("openrouter provider hooks", () => {
expect(speechProviders.map((provider) => provider.id)).toEqual(["openrouter"]);
expect(mediaProviders.map((provider) => provider.id)).toEqual(["openrouter"]);
expect(imageProviders.map((provider) => provider.id)).toEqual(["openrouter"]);
expect(musicProviders.map((provider) => provider.id)).toEqual(["openrouter"]);
expect(videoProviders.map((provider) => provider.id)).toEqual(["openrouter"]);
expect(modelCatalogProvider.liveCatalog).toBeTypeOf("function");
});
+3
View File
@@ -14,6 +14,7 @@ import {
} from "openclaw/plugin-sdk/provider-stream-family";
import { buildOpenRouterImageGenerationProvider } from "./image-generation-provider.js";
import { openrouterMediaUnderstandingProvider } from "./media-understanding-provider.js";
import { buildOpenRouterMusicGenerationProvider } from "./music-generation-provider.js";
import { applyOpenrouterConfig, OPENROUTER_DEFAULT_MODEL_REF } from "./onboard.js";
import {
buildOpenrouterProvider,
@@ -114,6 +115,7 @@ export default definePluginEntry({
groupId: "openrouter",
groupLabel: "OpenRouter",
groupHint: "API key",
onboardingScopes: ["text-inference", "music-generation"],
},
}),
],
@@ -168,6 +170,7 @@ export default definePluginEntry({
});
api.registerMediaUnderstandingProvider(openrouterMediaUnderstandingProvider);
api.registerImageGenerationProvider(buildOpenRouterImageGenerationProvider());
api.registerMusicGenerationProvider(buildOpenRouterMusicGenerationProvider());
api.registerVideoGenerationProvider(buildOpenRouterVideoGenerationProvider());
api.registerModelCatalogProvider({
provider: PROVIDER_ID,
@@ -0,0 +1,226 @@
import { expectExplicitMusicGenerationCapabilities } from "openclaw/plugin-sdk/provider-test-contracts";
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildOpenRouterMusicGenerationProvider } from "./music-generation-provider.js";
const {
assertOkOrThrowHttpErrorMock,
postJsonRequestMock,
resolveApiKeyForProviderMock,
resolveProviderHttpRequestConfigMock,
} = vi.hoisted(() => ({
assertOkOrThrowHttpErrorMock: vi.fn(async () => {}),
postJsonRequestMock: vi.fn(),
resolveApiKeyForProviderMock: vi.fn(async () => ({
apiKey: "openrouter-key",
source: "env",
mode: "api-key",
})),
resolveProviderHttpRequestConfigMock: vi.fn((params: Record<string, unknown>) => ({
baseUrl: params.baseUrl ?? params.defaultBaseUrl,
allowPrivateNetwork: false,
headers: new Headers(params.defaultHeaders as HeadersInit | undefined),
dispatcherPolicy: undefined,
})),
}));
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
}));
vi.mock("openclaw/plugin-sdk/provider-http", async (importOriginal) => {
const original = await importOriginal<typeof import("openclaw/plugin-sdk/provider-http")>();
return {
...original,
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
postJsonRequest: postJsonRequestMock,
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
};
});
function sseResponse(lines: string[]): Response {
const encoder = new TextEncoder();
return new Response(
new ReadableStream({
start(controller) {
for (const line of lines) {
controller.enqueue(encoder.encode(line));
}
controller.close();
},
}),
{ status: 200, headers: { "content-type": "text/event-stream" } },
);
}
function stalledSseResponse(line: string): Response {
const encoder = new TextEncoder();
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(line));
},
cancel() {},
}),
{ status: 200, headers: { "content-type": "text/event-stream" } },
);
}
function postRequest(): Record<string, unknown> {
const request = postJsonRequestMock.mock.calls[0]?.[0];
if (!request || typeof request !== "object" || Array.isArray(request)) {
throw new Error("expected OpenRouter music request");
}
return request as Record<string, unknown>;
}
describe("openrouter music generation provider", () => {
afterEach(() => {
assertOkOrThrowHttpErrorMock.mockClear();
postJsonRequestMock.mockReset();
resolveApiKeyForProviderMock.mockClear();
resolveProviderHttpRequestConfigMock.mockClear();
});
it("declares explicit mode capabilities", () => {
expectExplicitMusicGenerationCapabilities(buildOpenRouterMusicGenerationProvider());
});
it("streams OpenRouter audio chunks into a generated music asset", async () => {
const release = vi.fn(async () => {});
const audioBase64 = Buffer.from("wav-bytes").toString("base64");
postJsonRequestMock.mockResolvedValue({
response: sseResponse([
`data: ${JSON.stringify({ choices: [{ delta: { audio: { transcript: "line " } } }] })}\n`,
`data: ${JSON.stringify({ choices: [{ delta: { audio: { data: audioBase64.slice(0, 4) } } }] })}\n`,
`data: ${JSON.stringify({ choices: [{ delta: { audio: { data: audioBase64.slice(4), transcript: "two" } } }] })}\n`,
"data: [DONE]\n",
]),
release,
});
const result = await buildOpenRouterMusicGenerationProvider().generateMusic({
provider: "openrouter",
model: "",
prompt: "bright soundtrack",
cfg: {},
instrumental: true,
format: "wav",
});
expect(postRequest().url).toBe("https://openrouter.ai/api/v1/chat/completions");
expect(postRequest().body).toEqual({
model: "google/lyria-3-pro-preview",
messages: [
{
role: "user",
content:
"bright soundtrack\n\nInstrumental only. No vocals, no sung lyrics, no spoken word.",
},
],
modalities: ["text", "audio"],
audio: { format: "wav" },
stream: true,
});
expect(result.tracks[0]?.mimeType).toBe("audio/wav");
expect(result.tracks[0]?.buffer).toEqual(Buffer.from("wav-bytes"));
expect(result.lyrics).toEqual(["line two"]);
expect(release).toHaveBeenCalledOnce();
});
it("decodes independently padded OpenRouter audio chunks", async () => {
postJsonRequestMock.mockResolvedValue({
response: sseResponse([
`data: ${JSON.stringify({ choices: [{ delta: { audio: { data: Buffer.from("a").toString("base64") } } }] })}\n`,
`data: ${JSON.stringify({ choices: [{ delta: { audio: { data: Buffer.from("b").toString("base64") } } }] })}\n`,
"data: [DONE]\n",
]),
release: vi.fn(async () => {}),
});
const result = await buildOpenRouterMusicGenerationProvider().generateMusic({
provider: "openrouter",
model: "google/lyria-3-pro-preview",
prompt: "chunked soundtrack",
cfg: {},
});
expect(result.tracks[0]?.buffer).toEqual(Buffer.from("ab"));
});
it("sends reference images as multimodal message content", async () => {
postJsonRequestMock.mockResolvedValue({
response: sseResponse([
`data: ${JSON.stringify({ choices: [{ delta: { audio: { data: Buffer.from("mp3").toString("base64") } } }] })}\n`,
"data: [DONE]\n",
]),
release: vi.fn(async () => {}),
});
await buildOpenRouterMusicGenerationProvider().generateMusic({
provider: "openrouter",
model: "google/lyria-3-clip-preview",
prompt: "score this image",
cfg: {},
format: "mp3",
inputImages: [{ buffer: Buffer.from("png"), mimeType: "image/png" }],
});
expect(postRequest().body).toEqual(
expect.objectContaining({
model: "google/lyria-3-clip-preview",
audio: { format: "mp3" },
messages: [
{
role: "user",
content: [
{ type: "text", text: "score this image" },
{
type: "image_url",
image_url: {
url: `data:image/png;base64,${Buffer.from("png").toString("base64")}`,
},
},
],
},
],
}),
);
});
it("times out stalled OpenRouter audio streams after headers", async () => {
postJsonRequestMock.mockResolvedValue({
response: stalledSseResponse(
`data: ${JSON.stringify({ choices: [{ delta: { audio: { transcript: "start" } } }] })}\n`,
),
release: vi.fn(async () => {}),
});
await expect(
buildOpenRouterMusicGenerationProvider().generateMusic({
provider: "openrouter",
model: "google/lyria-3-clip-preview",
prompt: "never finish",
cfg: {},
timeoutMs: 1,
}),
).rejects.toThrow("OpenRouter music generation timed out after 1ms");
});
it("rejects OpenRouter streams that end before completion", async () => {
postJsonRequestMock.mockResolvedValue({
response: sseResponse([
`data: ${JSON.stringify({ choices: [{ delta: { audio: { data: Buffer.from("partial").toString("base64") } } }] })}\n`,
]),
release: vi.fn(async () => {}),
});
await expect(
buildOpenRouterMusicGenerationProvider().generateMusic({
provider: "openrouter",
model: "google/lyria-3-clip-preview",
prompt: "interrupted",
cfg: {},
}),
).rejects.toThrow("OpenRouter music generation stream ended before completion");
});
});
@@ -0,0 +1,344 @@
import type {
MusicGenerationProvider,
MusicGenerationRequest,
MusicGenerationSourceImage,
} from "openclaw/plugin-sdk/music-generation";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
assertOkOrThrowHttpError,
postJsonRequest,
resolveProviderHttpRequestConfig,
} from "openclaw/plugin-sdk/provider-http";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { OPENROUTER_BASE_URL } from "./provider-catalog.js";
const DEFAULT_OPENROUTER_MUSIC_MODEL = "google/lyria-3-pro-preview";
const OPENROUTER_CLIP_MUSIC_MODEL = "google/lyria-3-clip-preview";
const DEFAULT_TIMEOUT_MS = 180_000;
const OPENROUTER_MUSIC_MODELS = [
DEFAULT_OPENROUTER_MUSIC_MODEL,
OPENROUTER_CLIP_MUSIC_MODEL,
] as const;
type OpenRouterAudioStreamResult = {
audioBuffer: Buffer;
transcript: string;
};
type OpenRouterStreamDeadline = {
deadlineAtMs: number;
timeoutMs: number;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function resolveOpenRouterMusicModel(model: string | undefined): string {
return normalizeOptionalString(model) ?? DEFAULT_OPENROUTER_MUSIC_MODEL;
}
function outputFormatToMimeType(format: "mp3" | "wav" | undefined): string {
return format === "mp3" ? "audio/mpeg" : "audio/wav";
}
function imageToContentPart(image: MusicGenerationSourceImage): {
type: "image_url";
image_url: { url: string };
} {
const url =
normalizeOptionalString(image.url) ??
(image.buffer
? `data:${normalizeOptionalString(image.mimeType) ?? "image/png"};base64,${image.buffer.toString("base64")}`
: undefined);
if (!url) {
throw new Error("OpenRouter music generation reference image is missing data.");
}
return {
type: "image_url",
image_url: { url },
};
}
function buildOpenRouterMusicPrompt(req: MusicGenerationRequest): string {
const parts = [req.prompt.trim()];
const lyrics = normalizeOptionalString(req.lyrics);
if (req.instrumental === true) {
parts.push("Instrumental only. No vocals, no sung lyrics, no spoken word.");
}
if (lyrics) {
parts.push(`Lyrics:\n${lyrics}`);
}
if (typeof req.durationSeconds === "number") {
parts.push(`Target duration: about ${Math.round(req.durationSeconds)} seconds.`);
}
return parts.join("\n\n");
}
function buildOpenRouterMessageContent(
req: MusicGenerationRequest,
):
| string
| Array<{ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }> {
const prompt = buildOpenRouterMusicPrompt(req);
const images = req.inputImages ?? [];
if (images.length === 0) {
return prompt;
}
return [{ type: "text", text: prompt }, ...images.map((image) => imageToContentPart(image))];
}
function readDeltaAudio(part: unknown): { data?: string; transcript?: string } | undefined {
if (!isRecord(part)) {
return undefined;
}
const choices = part.choices;
if (!Array.isArray(choices)) {
return undefined;
}
const first = choices[0];
if (!isRecord(first)) {
return undefined;
}
const delta = first.delta;
if (!isRecord(delta)) {
return undefined;
}
const audio = delta.audio;
if (!isRecord(audio)) {
return undefined;
}
return {
data: normalizeOptionalString(audio.data),
transcript: typeof audio.transcript === "string" ? audio.transcript : undefined,
};
}
function processOpenRouterSseLine(
line: string,
result: { audioBuffers: Buffer[]; transcriptChunks: string[] },
): boolean {
if (!line.startsWith("data:")) {
return false;
}
const data = line.slice("data:".length).trim();
if (!data) {
return false;
}
if (data === "[DONE]") {
return true;
}
const audio = readDeltaAudio(JSON.parse(data));
if (audio?.data) {
result.audioBuffers.push(Buffer.from(audio.data, "base64"));
}
if (audio?.transcript) {
result.transcriptChunks.push(audio.transcript);
}
return false;
}
function createOpenRouterStreamDeadline(timeoutMs: number): OpenRouterStreamDeadline {
return {
deadlineAtMs: Date.now() + Math.max(1, Math.floor(timeoutMs)),
timeoutMs,
};
}
function resolveOpenRouterStreamRemainingMs(deadline: OpenRouterStreamDeadline): number {
const remainingMs = deadline.deadlineAtMs - Date.now();
if (remainingMs <= 0) {
throw new Error(`OpenRouter music generation timed out after ${deadline.timeoutMs}ms`);
}
return Math.max(1, remainingMs);
}
async function readOpenRouterStreamChunk(
reader: ReadableStreamDefaultReader<Uint8Array>,
deadline: OpenRouterStreamDeadline,
): Promise<ReadableStreamReadResult<Uint8Array>> {
const timeoutMs = resolveOpenRouterStreamRemainingMs(deadline);
let timeoutId: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
reader.read(),
new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(`OpenRouter music generation timed out after ${deadline.timeoutMs}ms`));
}, timeoutMs);
}),
]);
} catch (error) {
await reader.cancel().catch(() => {});
throw error;
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
async function readOpenRouterAudioStream(
response: Response,
deadline: OpenRouterStreamDeadline,
): Promise<OpenRouterAudioStreamResult> {
if (!response.body) {
throw new Error("OpenRouter music generation response missing stream body");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
const result = { audioBuffers: [] as Buffer[], transcriptChunks: [] as string[] };
let buffer = "";
let doneSeen = false;
for (;;) {
const { value, done } = await readOpenRouterStreamChunk(reader, deadline);
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split(/\r?\n/u);
buffer = lines.pop() ?? "";
for (const line of lines) {
if (processOpenRouterSseLine(line.trim(), result)) {
doneSeen = true;
await reader.cancel();
return {
audioBuffer: Buffer.concat(result.audioBuffers),
transcript: result.transcriptChunks.join(""),
};
}
}
}
resolveOpenRouterStreamRemainingMs(deadline);
buffer += decoder.decode();
if (buffer.trim()) {
for (const line of buffer.split(/\r?\n/u)) {
if (processOpenRouterSseLine(line.trim(), result)) {
doneSeen = true;
}
}
}
if (!doneSeen) {
throw new Error("OpenRouter music generation stream ended before completion");
}
return {
audioBuffer: Buffer.concat(result.audioBuffers),
transcript: result.transcriptChunks.join(""),
};
}
export function buildOpenRouterMusicGenerationProvider(): MusicGenerationProvider {
return {
id: "openrouter",
label: "OpenRouter",
defaultModel: DEFAULT_OPENROUTER_MUSIC_MODEL,
models: [...OPENROUTER_MUSIC_MODELS],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({
provider: "openrouter",
agentDir,
}),
capabilities: {
generate: {
maxTracks: 1,
maxDurationSeconds: 180,
supportsLyrics: true,
supportsInstrumental: true,
supportsDuration: true,
supportsFormat: true,
supportedFormats: ["mp3", "wav"],
},
edit: {
enabled: true,
maxTracks: 1,
maxInputImages: 1,
maxDurationSeconds: 180,
supportsLyrics: true,
supportsInstrumental: true,
supportsDuration: true,
supportsFormat: true,
supportedFormats: ["mp3", "wav"],
},
},
async generateMusic(req) {
if ((req.inputImages?.length ?? 0) > 1) {
throw new Error("OpenRouter music generation supports at most one reference image.");
}
const auth = await resolveApiKeyForProvider({
provider: "openrouter",
cfg: req.cfg,
agentDir: req.agentDir,
store: req.authStore,
});
if (!auth.apiKey) {
throw new Error("OpenRouter API key missing");
}
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
resolveProviderHttpRequestConfig({
baseUrl: req.cfg?.models?.providers?.openrouter?.baseUrl,
defaultBaseUrl: OPENROUTER_BASE_URL,
allowPrivateNetwork: false,
defaultHeaders: {
Authorization: `Bearer ${auth.apiKey}`,
"Content-Type": "application/json",
"HTTP-Referer": "https://openclaw.ai",
"X-OpenRouter-Title": "OpenClaw",
},
provider: "openrouter",
capability: "audio",
transport: "http",
});
const model = resolveOpenRouterMusicModel(req.model);
const format = req.format ?? "wav";
const timeoutMs = req.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const streamDeadline = createOpenRouterStreamDeadline(timeoutMs);
const { response, release } = await postJsonRequest({
url: `${baseUrl}/chat/completions`,
headers,
body: {
model,
messages: [{ role: "user", content: buildOpenRouterMessageContent(req) }],
modalities: ["text", "audio"],
audio: { format },
stream: true,
},
timeoutMs,
fetchFn: fetch,
allowPrivateNetwork,
dispatcherPolicy,
});
try {
await assertOkOrThrowHttpError(response, "OpenRouter music generation failed");
const streamResult = await readOpenRouterAudioStream(response, streamDeadline);
if (streamResult.audioBuffer.byteLength === 0) {
throw new Error("OpenRouter music generation response missing audio data");
}
return {
tracks: [
{
buffer: streamResult.audioBuffer,
mimeType: outputFormatToMimeType(format),
fileName: `track-1.${format}`,
},
],
model,
...(streamResult.transcript ? { lyrics: [streamResult.transcript] } : {}),
metadata: {
inputImageCount: req.inputImages?.length ?? 0,
instrumental: req.instrumental === true,
requestedFormat: format,
},
};
} finally {
await release();
}
},
};
}
export const _openRouterMusicTestInternals = {
readOpenRouterAudioStream,
};
@@ -47,6 +47,7 @@
"groupId": "openrouter",
"groupLabel": "OpenRouter",
"groupHint": "API key",
"onboardingScopes": ["text-inference", "music-generation"],
"optionKey": "openrouterApiKey",
"cliFlag": "--openrouter-api-key",
"cliOption": "--openrouter-api-key <key>",
@@ -56,6 +57,7 @@
"contracts": {
"mediaUnderstandingProviders": ["openrouter"],
"imageGenerationProviders": ["openrouter"],
"musicGenerationProviders": ["openrouter"],
"videoGenerationProviders": ["openrouter"],
"speechProviders": ["openrouter"]
},
@@ -19,6 +19,7 @@ export function createOpenrouterProvider(): ProviderPlugin {
groupId: "openrouter",
groupLabel: "OpenRouter",
groupHint: "API key",
onboardingScopes: ["text-inference", "music-generation"],
},
},
],
+1
View File
@@ -1,3 +1,4 @@
export { buildOpenRouterImageGenerationProvider } from "./image-generation-provider.js";
export { buildOpenRouterMusicGenerationProvider } from "./music-generation-provider.js";
export { openrouterMediaUnderstandingProvider } from "./media-understanding-provider.js";
export { buildOpenRouterSpeechProvider } from "./speech-provider.js";