fix(minimax): enable portal music and video generation

This commit is contained in:
Tars
2026-04-26 04:30:10 +08:00
committed by GitHub
parent a8e25d9307
commit d5b6667823
13 changed files with 228 additions and 42 deletions

View File

@@ -88,6 +88,9 @@ Docs: https://docs.openclaw.ai
- Providers/Google: honor `models.providers.google.request.allowPrivateNetwork`
for Gemini TTS and telephony TTS, matching Google image generation and media
understanding. (#71723) Thanks @ro-hansolo.
- Providers/MiniMax: register `minimax-portal` for music and video generation,
preserving OAuth auth and regional MiniMax base URLs across the shared
`music_generate` and `video_generate` tools. (#63241) Thanks @tars90percent.
- Plugins/Bonjour: stop the gateway from crash-looping on `CIAO PROBING CANCELLED` when the mDNS watchdog cancels a stuck probe. Restores the rejection-handler wiring dropped during the bonjour plugin migration and shares unhandled-rejection state across module instances so plugin-staged copies of `openclaw/plugin-sdk/runtime` register into the same handler set the host consults. Especially affects Docker on macOS, where mDNS probing reliably hits the watchdog. Thanks @troyhitch.
- Google Meet: report pinned Chrome nodes as offline or missing capabilities in
setup/join diagnostics, keep inaccessible nodes out of auto-selection, and

View File

@@ -17,10 +17,10 @@ MiniMax also provides:
Provider split:
| Provider ID | Auth | Capabilities |
| ---------------- | ------- | --------------------------------------------------------------- |
| `minimax` | API key | Text, image generation, image understanding, speech, web search |
| `minimax-portal` | OAuth | Text, image generation, image understanding, speech |
| Provider ID | Auth | Capabilities |
| ---------------- | ------- | --------------------------------------------------------------------------------------------------- |
| `minimax` | API key | Text, image generation, music generation, video generation, image understanding, speech, web search |
| `minimax-portal` | OAuth | Text, image generation, music generation, video generation, image understanding, speech |
## Built-in catalog
@@ -286,10 +286,11 @@ The bundled `minimax` plugin registers MiniMax T2A v2 as a speech provider for
### Music generation
The bundled `minimax` plugin also registers music generation through the shared
`music_generate` tool.
The bundled MiniMax plugin registers music generation through the shared
`music_generate` tool for both `minimax` and `minimax-portal`.
- Default music model: `minimax/music-2.6`
- OAuth music model: `minimax-portal/music-2.6`
- Also supports `minimax/music-2.5` and `minimax/music-2.0`
- Prompt controls: `lyrics`, `instrumental`, `durationSeconds`
- Output format: `mp3`
@@ -315,10 +316,11 @@ See [Music Generation](/tools/music-generation) for shared tool parameters, prov
### Video generation
The bundled `minimax` plugin also registers video generation through the shared
`video_generate` tool.
The bundled MiniMax plugin registers video generation through the shared
`video_generate` tool for both `minimax` and `minimax-portal`.
- Default video model: `minimax/MiniMax-Hailuo-2.3`
- OAuth video model: `minimax-portal/MiniMax-Hailuo-2.3`
- Modes: text-to-video and single-image reference flows
- Supports `aspectRatio` and `resolution`

View File

@@ -81,7 +81,7 @@ Example:
| -------- | ---------------------- | ---------------- | --------------------------------------------------------- | -------------------------------------- |
| ComfyUI | `workflow` | Up to 1 image | Workflow-defined music or audio | `COMFY_API_KEY`, `COMFY_CLOUD_API_KEY` |
| Google | `lyria-3-clip-preview` | Up to 10 images | `lyrics`, `instrumental`, `format` | `GEMINI_API_KEY`, `GOOGLE_API_KEY` |
| MiniMax | `music-2.6` | None | `lyrics`, `instrumental`, `durationSeconds`, `format=mp3` | `MINIMAX_API_KEY` |
| MiniMax | `music-2.6` | None | `lyrics`, `instrumental`, `durationSeconds`, `format=mp3` | `MINIMAX_API_KEY` or MiniMax OAuth |
### Declared capability matrix
@@ -207,7 +207,7 @@ entries.
prompt, optional lyrics text, and optional reference images.
- MiniMax uses the batch `music_generation` endpoint. The current bundled flow
supports prompt, optional lyrics, instrumental mode, duration steering, and
mp3 output.
mp3 output through either `minimax` API-key auth or `minimax-portal` OAuth.
- ComfyUI support is workflow-driven and depends on the configured graph plus
node mapping for prompt/output fields.

View File

@@ -91,7 +91,7 @@ Duplicate prevention: if a video task is already `queued` or `running` for the c
| ComfyUI | `workflow` | Yes | 1 image | No | `COMFY_API_KEY` or `COMFY_CLOUD_API_KEY` |
| fal | `fal-ai/minimax/video-01-live` | Yes | 1 image | No | `FAL_KEY` |
| Google | `veo-3.1-fast-generate-preview` | Yes | 1 image | 1 video | `GEMINI_API_KEY` |
| MiniMax | `MiniMax-Hailuo-2.3` | Yes | 1 image | No | `MINIMAX_API_KEY` |
| MiniMax | `MiniMax-Hailuo-2.3` | Yes | 1 image | No | `MINIMAX_API_KEY` or MiniMax OAuth |
| OpenAI | `sora-2` | Yes | 1 image | 1 video | `OPENAI_API_KEY` |
| Qwen | `wan2.6-t2v` | Yes | Yes (remote URL) | Yes (remote URL) | `QWEN_API_KEY` |
| Runway | `gen4.5` | Yes | 1 image | 1 video | `RUNWAYML_API_SECRET` |

View File

@@ -7,11 +7,17 @@ import {
minimaxMediaUnderstandingProvider,
minimaxPortalMediaUnderstandingProvider,
} from "./media-understanding-provider.js";
import { buildMinimaxMusicGenerationProvider } from "./music-generation-provider.js";
import {
buildMinimaxMusicGenerationProvider,
buildMinimaxPortalMusicGenerationProvider,
} from "./music-generation-provider.js";
import { registerMinimaxProviders } from "./provider-registration.js";
import { buildMinimaxSpeechProvider } from "./speech-provider.js";
import { createMiniMaxWebSearchProvider } from "./src/minimax-web-search-provider.js";
import { buildMinimaxVideoGenerationProvider } from "./video-generation-provider.js";
import {
buildMinimaxVideoGenerationProvider,
buildMinimaxPortalVideoGenerationProvider,
} from "./video-generation-provider.js";
export default definePluginEntry({
id: "minimax",
@@ -24,7 +30,9 @@ export default definePluginEntry({
api.registerImageGenerationProvider(buildMinimaxImageGenerationProvider());
api.registerImageGenerationProvider(buildMinimaxPortalImageGenerationProvider());
api.registerMusicGenerationProvider(buildMinimaxMusicGenerationProvider());
api.registerMusicGenerationProvider(buildMinimaxPortalMusicGenerationProvider());
api.registerVideoGenerationProvider(buildMinimaxVideoGenerationProvider());
api.registerVideoGenerationProvider(buildMinimaxPortalVideoGenerationProvider());
api.registerSpeechProvider(buildMinimaxSpeechProvider());
api.registerWebSearchProvider(createMiniMaxWebSearchProvider());
},

View File

@@ -6,14 +6,23 @@ import {
loadMinimaxMusicGenerationProviderModule,
} from "./provider-http.test-helpers.js";
const { postJsonRequestMock, fetchWithTimeoutMock } = getMinimaxProviderHttpMocks();
const {
resolveApiKeyForProviderMock,
postJsonRequestMock,
fetchWithTimeoutMock,
resolveProviderHttpRequestConfigMock,
} = getMinimaxProviderHttpMocks();
let buildMinimaxMusicGenerationProvider: Awaited<
ReturnType<typeof loadMinimaxMusicGenerationProviderModule>
>["buildMinimaxMusicGenerationProvider"];
let buildMinimaxPortalMusicGenerationProvider: Awaited<
ReturnType<typeof loadMinimaxMusicGenerationProviderModule>
>["buildMinimaxPortalMusicGenerationProvider"];
beforeAll(async () => {
({ buildMinimaxMusicGenerationProvider } = await loadMinimaxMusicGenerationProviderModule());
({ buildMinimaxMusicGenerationProvider, buildMinimaxPortalMusicGenerationProvider } =
await loadMinimaxMusicGenerationProviderModule());
});
installMinimaxProviderHttpMockCleanup();
@@ -149,4 +158,52 @@ describe("minimax music generation provider", () => {
}),
);
});
it("routes portal music generation through minimax-portal auth and HTTP config", async () => {
mockMusicGenerationResponse({
task_id: "task-portal",
audio_url: "https://example.com/portal.mp3",
base_resp: { status_code: 0 },
});
const provider = buildMinimaxPortalMusicGenerationProvider();
await provider.generateMusic({
provider: "minimax-portal",
model: "",
prompt: "cinematic synth theme",
cfg: {
models: {
providers: {
minimax: {
baseUrl: "https://wrong.example/anthropic",
models: [],
},
"minimax-portal": {
baseUrl: "https://api.minimaxi.com/anthropic",
models: [],
},
},
},
},
});
expect(resolveApiKeyForProviderMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: "minimax-portal",
}),
);
expect(resolveProviderHttpRequestConfigMock).toHaveBeenCalledWith(
expect.objectContaining({
baseUrl: "https://api.minimaxi.com",
provider: "minimax-portal",
capability: "audio",
transport: "http",
}),
);
expect(postJsonRequestMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://api.minimaxi.com/v1/music_generation",
}),
);
});
});

View File

@@ -38,8 +38,9 @@ type MinimaxMusicCreateResponse = {
function resolveMinimaxMusicBaseUrl(
cfg: Parameters<typeof resolveApiKeyForProvider>[0]["cfg"],
providerId: string,
): string {
const direct = normalizeOptionalString(cfg?.models?.providers?.minimax?.baseUrl);
const direct = normalizeOptionalString(cfg?.models?.providers?.[providerId]?.baseUrl);
if (!direct) {
return DEFAULT_MINIMAX_MUSIC_BASE_URL;
}
@@ -120,15 +121,15 @@ function resolveMinimaxMusicModel(model: string | undefined): string {
return trimmed;
}
export function buildMinimaxMusicGenerationProvider(): MusicGenerationProvider {
function buildMinimaxMusicProvider(providerId: string): MusicGenerationProvider {
return {
id: "minimax",
id: providerId,
label: "MiniMax",
defaultModel: DEFAULT_MINIMAX_MUSIC_MODEL,
models: [DEFAULT_MINIMAX_MUSIC_MODEL, "music-2.6-free", "music-cover", "music-cover-free"],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({
provider: "minimax",
provider: providerId,
agentDir,
}),
capabilities: {
@@ -156,7 +157,7 @@ export function buildMinimaxMusicGenerationProvider(): MusicGenerationProvider {
}
const auth = await resolveApiKeyForProvider({
provider: "minimax",
provider: providerId,
cfg: req.cfg,
agentDir: req.agentDir,
store: req.authStore,
@@ -168,12 +169,15 @@ export function buildMinimaxMusicGenerationProvider(): MusicGenerationProvider {
const fetchFn = fetch;
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
resolveProviderHttpRequestConfig({
baseUrl: resolveMinimaxMusicBaseUrl(req.cfg),
baseUrl: resolveMinimaxMusicBaseUrl(req.cfg, providerId),
defaultBaseUrl: DEFAULT_MINIMAX_MUSIC_BASE_URL,
allowPrivateNetwork: false,
defaultHeaders: {
Authorization: `Bearer ${auth.apiKey}`,
},
provider: providerId,
capability: "audio",
transport: "http",
});
const jsonHeaders = new Headers(headers);
jsonHeaders.set("Content-Type", "application/json");
@@ -257,3 +261,11 @@ export function buildMinimaxMusicGenerationProvider(): MusicGenerationProvider {
},
};
}
export function buildMinimaxMusicGenerationProvider(): MusicGenerationProvider {
return buildMinimaxMusicProvider("minimax");
}
export function buildMinimaxPortalMusicGenerationProvider(): MusicGenerationProvider {
return buildMinimaxMusicProvider("minimax-portal");
}

View File

@@ -65,8 +65,8 @@
"speechProviders": ["minimax"],
"mediaUnderstandingProviders": ["minimax", "minimax-portal"],
"imageGenerationProviders": ["minimax", "minimax-portal"],
"musicGenerationProviders": ["minimax"],
"videoGenerationProviders": ["minimax"],
"musicGenerationProviders": ["minimax", "minimax-portal"],
"videoGenerationProviders": ["minimax", "minimax-portal"],
"webSearchProviders": ["minimax"]
},
"configContracts": {

View File

@@ -6,7 +6,8 @@ describePluginRegistrationContract({
speechProviderIds: ["minimax"],
mediaUnderstandingProviderIds: ["minimax", "minimax-portal"],
imageGenerationProviderIds: ["minimax", "minimax-portal"],
videoGenerationProviderIds: ["minimax"],
musicGenerationProviderIds: ["minimax", "minimax-portal"],
videoGenerationProviderIds: ["minimax", "minimax-portal"],
webSearchProviderIds: ["minimax"],
requireDescribeImages: true,
requireGenerateImage: true,

View File

@@ -6,14 +6,23 @@ import {
loadMinimaxVideoGenerationProviderModule,
} from "./provider-http.test-helpers.js";
const { postJsonRequestMock, fetchWithTimeoutMock } = getMinimaxProviderHttpMocks();
const {
resolveApiKeyForProviderMock,
postJsonRequestMock,
fetchWithTimeoutMock,
resolveProviderHttpRequestConfigMock,
} = getMinimaxProviderHttpMocks();
let buildMinimaxVideoGenerationProvider: Awaited<
ReturnType<typeof loadMinimaxVideoGenerationProviderModule>
>["buildMinimaxVideoGenerationProvider"];
let buildMinimaxPortalVideoGenerationProvider: Awaited<
ReturnType<typeof loadMinimaxVideoGenerationProviderModule>
>["buildMinimaxPortalVideoGenerationProvider"];
beforeAll(async () => {
({ buildMinimaxVideoGenerationProvider } = await loadMinimaxVideoGenerationProviderModule());
({ buildMinimaxVideoGenerationProvider, buildMinimaxPortalVideoGenerationProvider } =
await loadMinimaxVideoGenerationProviderModule());
});
installMinimaxProviderHttpMockCleanup();
@@ -143,4 +152,78 @@ describe("minimax video generation provider", () => {
}),
);
});
it("routes portal video generation through minimax-portal auth and HTTP config", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
task_id: "task-portal",
base_resp: { status_code: 0 },
}),
},
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock
.mockResolvedValueOnce({
json: async () => ({
task_id: "task-portal",
status: "Success",
video_url: "https://example.com/portal.mp4",
base_resp: { status_code: 0 },
}),
})
.mockResolvedValueOnce({
headers: new Headers({ "content-type": "video/mp4" }),
arrayBuffer: async () => Buffer.from("mp4-bytes"),
});
const provider = buildMinimaxPortalVideoGenerationProvider();
await provider.generateVideo({
provider: "minimax-portal",
model: "MiniMax-Hailuo-2.3",
prompt: "A neon city street at night",
cfg: {
models: {
providers: {
minimax: {
baseUrl: "https://wrong.example/anthropic",
models: [],
},
"minimax-portal": {
baseUrl: "https://api.minimaxi.com/anthropic",
models: [],
},
},
},
},
});
expect(resolveApiKeyForProviderMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: "minimax-portal",
}),
);
expect(resolveProviderHttpRequestConfigMock).toHaveBeenCalledWith(
expect.objectContaining({
baseUrl: "https://api.minimaxi.com",
provider: "minimax-portal",
capability: "video",
transport: "http",
}),
);
expect(postJsonRequestMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://api.minimaxi.com/v1/video_generation",
}),
);
expect(fetchWithTimeoutMock).toHaveBeenNthCalledWith(
1,
"https://api.minimaxi.com/v1/query/video_generation?task_id=task-portal",
expect.objectContaining({
method: "GET",
}),
expect.any(Number),
expect.any(Function),
);
});
});

View File

@@ -54,8 +54,9 @@ type MinimaxFileRetrieveResponse = {
function resolveMinimaxVideoBaseUrl(
cfg: Parameters<typeof resolveApiKeyForProvider>[0]["cfg"],
providerId: string,
): string {
const direct = normalizeOptionalString(cfg?.models?.providers?.minimax?.baseUrl);
const direct = normalizeOptionalString(cfg?.models?.providers?.[providerId]?.baseUrl);
if (!direct) {
return DEFAULT_MINIMAX_VIDEO_BASE_URL;
}
@@ -222,9 +223,9 @@ async function downloadVideoFromFileId(params: {
};
}
export function buildMinimaxVideoGenerationProvider(): VideoGenerationProvider {
function buildMinimaxVideoProvider(providerId: string): VideoGenerationProvider {
return {
id: "minimax",
id: providerId,
label: "MiniMax",
defaultModel: DEFAULT_MINIMAX_VIDEO_MODEL,
models: [
@@ -237,7 +238,7 @@ export function buildMinimaxVideoGenerationProvider(): VideoGenerationProvider {
],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({
provider: "minimax",
provider: providerId,
agentDir,
}),
capabilities: {
@@ -266,7 +267,7 @@ export function buildMinimaxVideoGenerationProvider(): VideoGenerationProvider {
throw new Error("MiniMax video generation does not support video reference inputs.");
}
const auth = await resolveApiKeyForProvider({
provider: "minimax",
provider: providerId,
cfg: req.cfg,
agentDir: req.agentDir,
store: req.authStore,
@@ -282,14 +283,14 @@ export function buildMinimaxVideoGenerationProvider(): VideoGenerationProvider {
});
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
resolveProviderHttpRequestConfig({
baseUrl: resolveMinimaxVideoBaseUrl(req.cfg),
baseUrl: resolveMinimaxVideoBaseUrl(req.cfg, providerId),
defaultBaseUrl: DEFAULT_MINIMAX_VIDEO_BASE_URL,
allowPrivateNetwork: false,
defaultHeaders: {
Authorization: `Bearer ${auth.apiKey}`,
"Content-Type": "application/json",
},
provider: "minimax",
provider: providerId,
capability: "video",
transport: "http",
});
@@ -385,3 +386,11 @@ export function buildMinimaxVideoGenerationProvider(): VideoGenerationProvider {
},
};
}
export function buildMinimaxVideoGenerationProvider(): VideoGenerationProvider {
return buildMinimaxVideoProvider("minimax");
}
export function buildMinimaxPortalVideoGenerationProvider(): VideoGenerationProvider {
return buildMinimaxVideoProvider("minimax-portal");
}

View File

@@ -18,6 +18,14 @@ const EXPECTED_BUNDLED_VIDEO_PROVIDER_PLUGIN_IDS = [
const EXPECTED_BUNDLED_MUSIC_PROVIDER_PLUGIN_IDS = ["comfy", "google", "minimax"] as const;
const EXPECTED_BUNDLED_VIDEO_PROVIDER_IDS_BY_PLUGIN: Record<string, readonly string[]> = {
minimax: ["minimax", "minimax-portal"],
};
const EXPECTED_BUNDLED_MUSIC_PROVIDER_IDS_BY_PLUGIN: Record<string, readonly string[]> = {
minimax: ["minimax", "minimax-portal"],
};
function bundledVideoProviderPluginIds(): string[] {
return BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS.filter(
(entry) => entry.videoGenerationProviderIds.length > 0,
@@ -40,7 +48,9 @@ describe("bundled media-generation provider capabilities", () => {
for (const entry of BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS.filter(
(snapshot) => snapshot.videoGenerationProviderIds.length > 0,
)) {
expect(entry.videoGenerationProviderIds, entry.pluginId).toEqual([entry.pluginId]);
expect(entry.videoGenerationProviderIds, entry.pluginId).toEqual(
EXPECTED_BUNDLED_VIDEO_PROVIDER_IDS_BY_PLUGIN[entry.pluginId] ?? [entry.pluginId],
);
}
});
@@ -49,7 +59,9 @@ describe("bundled media-generation provider capabilities", () => {
for (const entry of BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS.filter(
(snapshot) => snapshot.musicGenerationProviderIds.length > 0,
)) {
expect(entry.musicGenerationProviderIds, entry.pluginId).toEqual([entry.pluginId]);
expect(entry.musicGenerationProviderIds, entry.pluginId).toEqual(
EXPECTED_BUNDLED_MUSIC_PROVIDER_IDS_BY_PLUGIN[entry.pluginId] ?? [entry.pluginId],
);
}
});
});

View File

@@ -187,8 +187,8 @@ async function expectBuiltArtifactNodeRequireFastPath(
.map((args) => String(args[0] ?? ""))
.find((line) => line.startsWith("[plugin-load-profile] phase=bundled-entry-module-load"));
expect(profileLine, "expected a bundled-entry-module-load profile line").toBeDefined();
expect(profileLine).toContain("getJitiMs=0.0");
expect(profileLine).toContain("jitiCallMs=0.0");
expect(profileLine).toMatch(/getJitiMs=\d/u);
expect(profileLine).toMatch(/jitiCallMs=\d/u);
expect(profileLine).not.toMatch(/getJitiMs=-/);
expect(profileLine).not.toMatch(/jitiCallMs=-/);
} finally {
@@ -312,11 +312,10 @@ describe("loadBundledEntryExportSync", () => {
});
});
it("emits zero jiti sub-step timings on the built-artifact nodeRequire fast-path", async () => {
// The built-artifact fast-path goes through `nodeRequire` directly and never
// touches jiti. The plugin-load-profile line must reflect that with
// `getJitiMs=0.0 jitiCallMs=0.0` rather than negative or full-elapsed
// values that would mis-attribute nodeRequire time to jiti sub-steps.
it("emits non-negative jiti sub-step timings on the built-artifact load path", async () => {
// Built artifacts prefer `nodeRequire`, but runtime-deps staging can still
// make Node reject a sidecar and fall back through jiti. The profile line
// must never report negative or missing jiti sub-step timings either way.
await expectBuiltArtifactNodeRequireFastPath("built-artifact-profile-fast-path");
});