mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-21 01:51:39 -06:00
51c41c25c8
Co-authored-by: Peter Steinberger <steipete@macos.shared>
332 lines
11 KiB
TypeScript
332 lines
11 KiB
TypeScript
// Minimax tests cover tts plugin behavior.
|
|
import { createServer } from "node:http";
|
|
import type { AddressInfo } from "node:net";
|
|
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
|
|
return {
|
|
...actual,
|
|
fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args),
|
|
};
|
|
});
|
|
|
|
import {
|
|
buildMinimaxMusicGenerationProvider,
|
|
buildMinimaxPortalMusicGenerationProvider,
|
|
} from "./music-generation-provider.js";
|
|
import { buildMinimaxSpeechProvider } from "./speech-provider.js";
|
|
import { minimaxTTS } from "./tts.js";
|
|
|
|
describe("minimaxTTS", () => {
|
|
afterEach(() => {
|
|
fetchWithSsrFGuardMock.mockReset();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("caps oversized request timeout before arming abort timers", async () => {
|
|
const timeoutSpy = vi
|
|
.spyOn(globalThis, "setTimeout")
|
|
.mockReturnValue(0 as unknown as ReturnType<typeof setTimeout>);
|
|
fetchWithSsrFGuardMock.mockResolvedValue({
|
|
response: new Response(
|
|
JSON.stringify({ data: { audio: Buffer.from("audio").toString("hex") } }),
|
|
{ status: 200, headers: { "content-type": "application/json" } },
|
|
),
|
|
release: vi.fn(async () => undefined),
|
|
});
|
|
|
|
const audio = await minimaxTTS({
|
|
text: "hello",
|
|
apiKey: "sk-test",
|
|
baseUrl: "https://api.minimax.io",
|
|
model: "speech-2.8-hd",
|
|
voiceId: "English_expressive_narrator",
|
|
timeoutMs: Number.MAX_SAFE_INTEGER,
|
|
});
|
|
|
|
expect(audio.toString()).toBe("audio");
|
|
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
|
|
expect(fetchWithSsrFGuardMock.mock.calls[0]?.[0]).toMatchObject({
|
|
timeoutMs: MAX_TIMER_TIMEOUT_MS,
|
|
});
|
|
expect(fetchWithSsrFGuardMock.mock.calls[0]?.[0]?.init?.signal).toBeInstanceOf(AbortSignal);
|
|
});
|
|
|
|
it("throws on base_resp envelope error even when data.audio is present (regression #76904)", async () => {
|
|
fetchWithSsrFGuardMock.mockResolvedValue({
|
|
response: new Response(
|
|
JSON.stringify({
|
|
data: { audio: Buffer.from("placeholder").toString("hex") },
|
|
base_resp: { status_code: 1002, status_msg: "Quota exceeded" },
|
|
}),
|
|
{ status: 200, headers: { "content-type": "application/json" } },
|
|
),
|
|
release: vi.fn(async () => undefined),
|
|
});
|
|
|
|
await expect(
|
|
minimaxTTS({
|
|
text: "hello",
|
|
apiKey: "sk-test",
|
|
baseUrl: "https://api.minimax.io",
|
|
model: "speech-2.8-hd",
|
|
voiceId: "English_expressive_narrator",
|
|
timeoutMs: 10_000,
|
|
}),
|
|
).rejects.toThrow("MiniMax TTS API error (1002): Quota exceeded");
|
|
});
|
|
|
|
it("throws on base_resp envelope error with empty audio", async () => {
|
|
fetchWithSsrFGuardMock.mockResolvedValue({
|
|
response: new Response(
|
|
JSON.stringify({
|
|
base_resp: { status_code: 1001, status_msg: "Rate limit" },
|
|
}),
|
|
{ status: 200, headers: { "content-type": "application/json" } },
|
|
),
|
|
release: vi.fn(async () => undefined),
|
|
});
|
|
|
|
await expect(
|
|
minimaxTTS({
|
|
text: "hello",
|
|
apiKey: "sk-test",
|
|
baseUrl: "https://api.minimax.io",
|
|
model: "speech-2.8-hd",
|
|
voiceId: "English_expressive_narrator",
|
|
timeoutMs: 10_000,
|
|
}),
|
|
).rejects.toThrow("MiniMax TTS API error (1001): Rate limit");
|
|
});
|
|
|
|
it("succeeds when base_resp.status_code is 0", async () => {
|
|
fetchWithSsrFGuardMock.mockResolvedValue({
|
|
response: new Response(
|
|
JSON.stringify({
|
|
data: { audio: Buffer.from("real-audio").toString("hex") },
|
|
base_resp: { status_code: 0, status_msg: "success" },
|
|
}),
|
|
{ status: 200, headers: { "content-type": "application/json" } },
|
|
),
|
|
release: vi.fn(async () => undefined),
|
|
});
|
|
|
|
const audio = await minimaxTTS({
|
|
text: "hello",
|
|
apiKey: "sk-test",
|
|
baseUrl: "https://api.minimax.io",
|
|
model: "speech-2.8-hd",
|
|
voiceId: "English_expressive_narrator",
|
|
timeoutMs: 10_000,
|
|
});
|
|
|
|
expect(audio.toString()).toBe("real-audio");
|
|
});
|
|
});
|
|
|
|
type MinimaxWireFixture = {
|
|
entryPoint: "tts" | "speech" | "music";
|
|
provider?: "minimax" | "minimax-portal";
|
|
audio?: string;
|
|
frames?: Array<{ status?: number | string; audio?: string } | "[DONE]">;
|
|
mediaMaxMb?: number;
|
|
};
|
|
|
|
async function runMinimaxLoopbackFixture(fixture: MinimaxWireFixture): Promise<Buffer> {
|
|
const originalFetch = globalThis.fetch;
|
|
const server = createServer((request, response) => {
|
|
request.resume();
|
|
if (fixture.entryPoint === "music") {
|
|
response.writeHead(200, { "content-type": "text/event-stream" });
|
|
response.end(
|
|
fixture.frames
|
|
?.map((frame) =>
|
|
frame === "[DONE]"
|
|
? "data: [DONE]"
|
|
: `data: ${JSON.stringify({
|
|
data: frame,
|
|
base_resp: { status_code: 0, status_msg: "success" },
|
|
})}`,
|
|
)
|
|
.join("\n\n") + "\n\n",
|
|
);
|
|
return;
|
|
}
|
|
response.writeHead(200, { "content-type": "application/json" });
|
|
response.end(
|
|
JSON.stringify({
|
|
data: { audio: fixture.audio, status: 2 },
|
|
base_resp: { status_code: 0, status_msg: "success" },
|
|
}),
|
|
);
|
|
});
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", resolve);
|
|
});
|
|
const { port } = server.address() as AddressInfo;
|
|
const pathname = fixture.entryPoint === "music" ? "/v1/music_generation" : "/v1/t2a_v2";
|
|
|
|
vi.stubEnv("MINIMAX_API_KEY", "fixture-provider-key");
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (_input, init) => {
|
|
const { dispatcher: _dispatcher, ...forwardedInit } = (init ?? {}) as RequestInit & {
|
|
dispatcher?: unknown;
|
|
};
|
|
return originalFetch(`http://127.0.0.1:${port}${pathname}`, forwardedInit);
|
|
});
|
|
fetchWithSsrFGuardMock.mockImplementation(async ({ url, init }) => ({
|
|
response: await fetch(url, init),
|
|
release: async () => {},
|
|
}));
|
|
|
|
try {
|
|
if (fixture.entryPoint === "tts") {
|
|
return await minimaxTTS({
|
|
text: "loopback fixture",
|
|
apiKey: "fixture-provider-key",
|
|
baseUrl: "https://api.minimax.io",
|
|
model: "speech-2.8-hd",
|
|
voiceId: "English_expressive_narrator",
|
|
timeoutMs: 1_000,
|
|
});
|
|
}
|
|
if (fixture.entryPoint === "speech") {
|
|
const result = await buildMinimaxSpeechProvider().synthesize({
|
|
text: "loopback fixture",
|
|
cfg: {},
|
|
providerConfig: { apiKey: "fixture-provider-key", baseUrl: "https://api.minimax.io" },
|
|
target: "audio-file",
|
|
timeoutMs: 1_000,
|
|
});
|
|
return result.audioBuffer;
|
|
}
|
|
|
|
const providerId = fixture.provider ?? "minimax";
|
|
const provider =
|
|
providerId === "minimax-portal"
|
|
? buildMinimaxPortalMusicGenerationProvider()
|
|
: buildMinimaxMusicGenerationProvider();
|
|
const result = await provider.generateMusic({
|
|
provider: providerId,
|
|
model: "music-2.6",
|
|
prompt: "loopback fixture",
|
|
cfg:
|
|
fixture.mediaMaxMb === undefined
|
|
? {}
|
|
: { agents: { defaults: { mediaMaxMb: fixture.mediaMaxMb } } },
|
|
});
|
|
const track = result.tracks[0];
|
|
if (!track) {
|
|
throw new Error("Music provider returned no track");
|
|
}
|
|
return track.buffer;
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.close((error) => {
|
|
if (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
resolve();
|
|
});
|
|
});
|
|
fetchWithSsrFGuardMock.mockReset();
|
|
vi.unstubAllEnvs();
|
|
vi.restoreAllMocks();
|
|
}
|
|
}
|
|
|
|
describe("MiniMax media producers through real localhost HTTP", () => {
|
|
it.each([
|
|
{ name: "trailing non-hex", audio: "666f6fZZ" },
|
|
{ name: "odd-length hex", audio: "666f6" },
|
|
{ name: "entirely non-hex", audio: "ZZ" },
|
|
])("rejects $name TTS audio without truncating it", async ({ audio }) => {
|
|
await expect(runMinimaxLoopbackFixture({ entryPoint: "tts", audio })).rejects.toThrow(
|
|
"MiniMax TTS API returned malformed hex audio",
|
|
);
|
|
});
|
|
|
|
it("reports malformed audio through the user-facing speech provider", async () => {
|
|
await expect(
|
|
runMinimaxLoopbackFixture({ entryPoint: "speech", audio: "666f6fZZ" }),
|
|
).rejects.toThrow("MiniMax TTS API returned malformed hex audio");
|
|
});
|
|
|
|
it.each([
|
|
{ provider: "minimax" as const, done: false },
|
|
{ provider: "minimax-portal" as const, done: true },
|
|
])(
|
|
"rejects incomplete $provider music after its HTTP stream closes",
|
|
async ({ provider, done }) => {
|
|
const frames: MinimaxWireFixture["frames"] = [
|
|
{ status: 1, audio: Buffer.from("partial-audio").toString("hex") },
|
|
...(done ? (["[DONE]"] as const) : []),
|
|
];
|
|
await expect(
|
|
runMinimaxLoopbackFixture({ entryPoint: "music", provider, frames }),
|
|
).rejects.toThrow("MiniMax music generation stream ended without completion");
|
|
},
|
|
);
|
|
|
|
it.each([{ status: 2 }, { status: "2" }])(
|
|
"accepts a terminal music frame with status $status",
|
|
async ({ status }) => {
|
|
const audio = Buffer.from("complete-audio");
|
|
await expect(
|
|
runMinimaxLoopbackFixture({
|
|
entryPoint: "music",
|
|
frames: [{ status, audio: audio.toString("hex") }],
|
|
}),
|
|
).resolves.toEqual(audio);
|
|
},
|
|
);
|
|
|
|
it("preserves progressive audio when its terminal frame repeats the aggregate", async () => {
|
|
const first = Buffer.from("first-");
|
|
const second = Buffer.from("second");
|
|
await expect(
|
|
runMinimaxLoopbackFixture({
|
|
entryPoint: "music",
|
|
provider: "minimax-portal",
|
|
frames: [
|
|
{ status: 1, audio: first.toString("hex") },
|
|
{ status: 1, audio: second.toString("hex") },
|
|
{ status: 2, audio: Buffer.concat([first, second]).toString("hex") },
|
|
],
|
|
}),
|
|
).resolves.toEqual(Buffer.concat([first, second]));
|
|
});
|
|
|
|
it("accepts a terminal status without audio after progressive audio", async () => {
|
|
const audio = Buffer.from("complete-progress");
|
|
await expect(
|
|
runMinimaxLoopbackFixture({
|
|
entryPoint: "music",
|
|
frames: [{ status: 1, audio: audio.toString("hex") }, { status: 2 }, "[DONE]"],
|
|
}),
|
|
).resolves.toEqual(audio);
|
|
});
|
|
|
|
it("keeps the configured music byte limit before decoding terminal audio", async () => {
|
|
await expect(
|
|
runMinimaxLoopbackFixture({
|
|
entryPoint: "music",
|
|
frames: [{ status: 2, audio: Buffer.from("oversized").toString("hex") }],
|
|
mediaMaxMb: 0.000_001,
|
|
}),
|
|
).rejects.toThrow("MiniMax generated music download exceeds 1 bytes");
|
|
});
|
|
|
|
it("keeps valid speech-provider audio unchanged", async () => {
|
|
const audio = Buffer.from("complete-audio");
|
|
await expect(
|
|
runMinimaxLoopbackFixture({ entryPoint: "speech", audio: audio.toString("hex") }),
|
|
).resolves.toEqual(audio);
|
|
});
|
|
});
|