// Minimax tests cover speech provider plugin behavior. import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { clearRuntimeAuthProfileStoreSnapshots, saveAuthProfileStore, type AuthProfileStore, } from "openclaw/plugin-sdk/agent-runtime"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const transcodeAudioBufferToOpusMock = vi.hoisted(() => vi.fn()); vi.mock("openclaw/plugin-sdk/media-runtime", () => ({ transcodeAudioBufferToOpus: transcodeAudioBufferToOpusMock, })); import { buildMinimaxSpeechProvider } from "./speech-provider.js"; function clearMinimaxAuthEnv() { delete process.env.MINIMAX_API_KEY; delete process.env.MINIMAX_OAUTH_TOKEN; delete process.env.MINIMAX_CODE_PLAN_KEY; delete process.env.MINIMAX_CODING_API_KEY; } function minimaxPortalStore(): AuthProfileStore { return { version: 1, profiles: { "minimax-portal:test": { type: "token", provider: "minimax-portal", token: "portal-token", }, }, }; } function seedMinimaxPortalProfile(agentDir: string) { saveAuthProfileStore(minimaxPortalStore(), agentDir, { filterExternalAuthProfiles: false, syncExternalCli: false, }); } describe("buildMinimaxSpeechProvider", () => { const provider = buildMinimaxSpeechProvider(); function resolveProviderConfig( params: Parameters>[0], ): ReturnType> { const resolveConfig = provider.resolveConfig; if (!resolveConfig) { throw new Error("MiniMax speech provider did not expose config resolution"); } return resolveConfig(params); } function parseDirectiveToken( params: Parameters>[0], ): ReturnType> { const parseToken = provider.parseDirectiveToken; if (!parseToken) { throw new Error("MiniMax speech provider did not expose directive parsing"); } return parseToken(params); } describe("metadata", () => { it("has correct id and label", () => { expect(provider.id).toBe("minimax"); expect(provider.label).toBe("MiniMax"); }); it("has autoSelectOrder 40", () => { expect(provider.autoSelectOrder).toBe(40); }); it("exposes models and voices", () => { expect(provider.models).toEqual([ "speech-2.8-hd", "speech-2.8-turbo", "speech-2.6-hd", "speech-2.6-turbo", "speech-02-hd", "speech-02-turbo", "speech-01-hd", "speech-01-turbo", "speech-01-240228", ]); expect(provider.voices).toContain("English_expressive_narrator"); }); }); describe("isConfigured", () => { const savedEnv = { ...process.env }; let tempStateDir: string; let tempAgentDir: string; let tokenPlanEnvConfigured = false; beforeAll(() => { const previous = process.env.MINIMAX_CODING_API_KEY; try { process.env.MINIMAX_CODING_API_KEY = "sk-cp-env"; tokenPlanEnvConfigured = provider.isConfigured({ providerConfig: {}, timeoutMs: 30000, }); } finally { if (previous === undefined) { delete process.env.MINIMAX_CODING_API_KEY; } else { process.env.MINIMAX_CODING_API_KEY = previous; } } }); beforeEach(async () => { tempStateDir = await mkdtemp(path.join(tmpdir(), "openclaw-minimax-tts-auth-")); tempAgentDir = path.join(tempStateDir, "agents", "main", "agent"); await mkdir(tempAgentDir, { recursive: true }); process.env.OPENCLAW_STATE_DIR = tempStateDir; process.env.OPENCLAW_AGENT_DIR = tempAgentDir; clearMinimaxAuthEnv(); clearRuntimeAuthProfileStoreSnapshots(); }); afterEach(async () => { clearRuntimeAuthProfileStoreSnapshots(); process.env = { ...savedEnv }; await rm(tempStateDir, { recursive: true, force: true }); }); it("returns true when apiKey is in provider config", () => { expect( provider.isConfigured({ providerConfig: { apiKey: "sk-test" }, timeoutMs: 30000 }), ).toBe(true); }); it("returns false when no apiKey anywhere", () => { expect(provider.isConfigured({ providerConfig: {}, timeoutMs: 30000 })).toBe(false); }); it("returns true when MINIMAX_API_KEY env var is set", () => { process.env.MINIMAX_API_KEY = "sk-env"; expect(provider.isConfigured({ providerConfig: {}, timeoutMs: 30000 })).toBe(true); }); it("returns true when a MiniMax Token Plan env var is set", () => { expect(tokenPlanEnvConfigured).toBe(true); }); it("returns true when a MiniMax portal auth profile is available", async () => { seedMinimaxPortalProfile(tempAgentDir); expect(provider.isConfigured({ providerConfig: {}, timeoutMs: 30000 })).toBe(true); }); }); describe("resolveConfig", () => { const savedEnv = { ...process.env }; afterEach(() => { process.env = { ...savedEnv }; }); it("returns defaults when rawConfig is empty", () => { delete process.env.MINIMAX_API_HOST; delete process.env.MINIMAX_TTS_MODEL; delete process.env.MINIMAX_TTS_VOICE_ID; const config = resolveProviderConfig({ rawConfig: {}, cfg: {} as never, timeoutMs: 30000 }); expect(config.baseUrl).toBe("https://api.minimax.io"); expect(config.model).toBe("speech-2.8-hd"); expect(config.voiceId).toBe("English_expressive_narrator"); }); it("reads from providers.minimax in rawConfig", () => { const config = resolveProviderConfig({ rawConfig: { providers: { minimax: { baseUrl: "https://custom.api.com", model: "speech-01-240228", voiceId: "Chinese (Mandarin)_Warm_Girl", speed: 1.5, vol: 2, pitch: 3, }, }, }, cfg: {} as never, timeoutMs: 30000, }); expect(config.baseUrl).toBe("https://custom.api.com"); expect(config.model).toBe("speech-01-240228"); expect(config.voiceId).toBe("Chinese (Mandarin)_Warm_Girl"); expect(config.speed).toBe(1.5); expect(config.vol).toBe(2); expect(config.pitch).toBe(3); }); it("keeps trusted MINIMAX_API_HOST fallback for TTS baseUrl", () => { process.env.MINIMAX_API_HOST = "https://api.minimax.io/anthropic"; process.env.MINIMAX_TTS_MODEL = "speech-01-240228"; process.env.MINIMAX_TTS_VOICE_ID = "Chinese (Mandarin)_Gentle_Boy"; const config = resolveProviderConfig({ rawConfig: {}, cfg: {} as never, timeoutMs: 30000 }); expect(config.baseUrl).toBe("https://api.minimax.io"); expect(config.model).toBe("speech-01-240228"); expect(config.voiceId).toBe("Chinese (Mandarin)_Gentle_Boy"); }); it("derives the TTS host from minimax-portal OAuth config", () => { delete process.env.MINIMAX_API_HOST; const config = resolveProviderConfig({ rawConfig: {}, cfg: { models: { providers: { "minimax-portal": { baseUrl: "https://api.minimaxi.com/anthropic" }, }, }, } as never, timeoutMs: 30000, }); expect(config.baseUrl).toBe("https://api.minimaxi.com"); }); }); describe("parseDirectiveToken", () => { const policy = { enabled: true, allowText: true, allowProvider: true, allowVoice: true, allowModelId: true, allowVoiceSettings: true, allowNormalization: true, allowSeed: true, }; it("handles voice key", () => { const result = parseDirectiveToken({ key: "voice", value: "Chinese (Mandarin)_Warm_Girl", policy, }); expect(result.handled).toBe(true); expect(result.overrides?.voiceId).toBe("Chinese (Mandarin)_Warm_Girl"); }); it("handles voiceid key", () => { const result = parseDirectiveToken({ key: "voiceid", value: "test_voice", policy }); expect(result.handled).toBe(true); expect(result.overrides?.voiceId).toBe("test_voice"); }); it("handles model key", () => { const result = parseDirectiveToken({ key: "model", value: "speech-01-240228", policy, }); expect(result.handled).toBe(true); expect(result.overrides?.model).toBe("speech-01-240228"); }); it("handles speed key with valid value", () => { const result = parseDirectiveToken({ key: "speed", value: "1.5", policy }); expect(result.handled).toBe(true); expect(result.overrides?.speed).toBe(1.5); }); it("warns on invalid speed", () => { const result = parseDirectiveToken({ key: "speed", value: "5.0", policy }); expect(result.handled).toBe(true); expect(result.warnings).toHaveLength(1); expect(result.overrides).toBeUndefined(); }); it("warns on non-decimal speed values", () => { const result = parseDirectiveToken({ key: "speed", value: "0x1", policy }); expect(result.handled).toBe(true); expect(result.warnings).toHaveLength(1); expect(result.overrides).toBeUndefined(); }); it("handles vol key", () => { const result = parseDirectiveToken({ key: "vol", value: "3", policy }); expect(result.handled).toBe(true); expect(result.overrides?.vol).toBe(3); }); it("warns on vol=0 (exclusive minimum)", () => { const result = parseDirectiveToken({ key: "vol", value: "0", policy }); expect(result.handled).toBe(true); expect(result.warnings).toHaveLength(1); }); it("warns on non-decimal volume values", () => { const result = parseDirectiveToken({ key: "vol", value: "0x3", policy }); expect(result.handled).toBe(true); expect(result.warnings).toHaveLength(1); expect(result.overrides).toBeUndefined(); }); it("handles volume alias", () => { const result = parseDirectiveToken({ key: "volume", value: "5", policy }); expect(result.handled).toBe(true); expect(result.overrides?.vol).toBe(5); }); it("handles pitch key", () => { const result = parseDirectiveToken({ key: "pitch", value: "-3", policy }); expect(result.handled).toBe(true); expect(result.overrides?.pitch).toBe(-3); }); it("warns on out-of-range pitch", () => { const result = parseDirectiveToken({ key: "pitch", value: "20", policy }); expect(result.handled).toBe(true); expect(result.warnings).toHaveLength(1); }); it("warns on non-decimal pitch values", () => { const result = parseDirectiveToken({ key: "pitch", value: "0x3", policy }); expect(result.handled).toBe(true); expect(result.warnings).toHaveLength(1); expect(result.overrides).toBeUndefined(); }); it("returns handled=false for unknown keys", () => { const result = parseDirectiveToken({ key: "unknown_key", value: "whatever", policy, }); expect(result.handled).toBe(false); }); it("suppresses voice when policy disallows it", () => { const result = parseDirectiveToken({ key: "voice", value: "test", policy: { ...policy, allowVoice: false }, }); expect(result.handled).toBe(true); expect(result.overrides).toBeUndefined(); }); it("suppresses model when policy disallows it", () => { const result = parseDirectiveToken({ key: "model", value: "test", policy: { ...policy, allowModelId: false }, }); expect(result.handled).toBe(true); expect(result.overrides).toBeUndefined(); }); }); describe("synthesize", () => { const savedFetch = globalThis.fetch; const savedEnv = { ...process.env }; let tempStateDir: string; let tempAgentDir: string; beforeEach(async () => { tempStateDir = await mkdtemp(path.join(tmpdir(), "openclaw-minimax-tts-synth-")); tempAgentDir = path.join(tempStateDir, "agents", "main", "agent"); await mkdir(tempAgentDir, { recursive: true }); process.env = { ...savedEnv, OPENCLAW_AGENT_DIR: tempAgentDir, OPENCLAW_STATE_DIR: tempStateDir, }; clearMinimaxAuthEnv(); clearRuntimeAuthProfileStoreSnapshots(); vi.stubGlobal("fetch", vi.fn()); transcodeAudioBufferToOpusMock.mockReset(); }); afterEach(async () => { globalThis.fetch = savedFetch; process.env = { ...savedEnv }; clearRuntimeAuthProfileStoreSnapshots(); vi.restoreAllMocks(); await rm(tempStateDir, { recursive: true, force: true }); }); function firstFetchCall(): unknown[] { const call = vi.mocked(globalThis.fetch).mock.calls[0]; if (!call) { throw new Error("Expected MiniMax TTS fetch call"); } return call as unknown[]; } function firstFetchInit(): RequestInit | undefined { return firstFetchCall()[1] as RequestInit | undefined; } function firstFetchBody(): Record { const init = firstFetchInit(); if (typeof init?.body !== "string") { throw new Error("Expected MiniMax TTS fetch init body"); } return JSON.parse(init.body) as Record; } it("requests non-streaming hex audio and decodes the hex response", async () => { const hexAudio = Buffer.from("fake-audio-data").toString("hex"); const mockFetch = vi.mocked(globalThis.fetch); mockFetch.mockResolvedValueOnce( new Response(JSON.stringify({ data: { audio: hexAudio } }), { status: 200, headers: { "Content-Type": "application/json" }, }), ); const result = await provider.synthesize({ text: "Hello world", cfg: {} as never, providerConfig: { apiKey: "sk-test", baseUrl: "https://api.minimaxi.com" }, target: "audio-file", timeoutMs: 30000, }); expect(result.outputFormat).toBe("mp3"); expect(result.fileExtension).toBe(".mp3"); expect(result.voiceCompatible).toBe(false); expect(result.audioBuffer.toString()).toBe("fake-audio-data"); expect(mockFetch).toHaveBeenCalledOnce(); const url = firstFetchCall()[0]; expect(url).toBe("https://api.minimaxi.com/v1/t2a_v2"); const body = firstFetchBody(); expect(body.model).toBe("speech-2.8-hd"); expect(body.text).toBe("Hello world"); expect(body.stream).toBe(false); expect(body.output_format).toBe("hex"); expect((body.voice_setting as Record).voice_id).toBe( "English_expressive_narrator", ); expect(transcodeAudioBufferToOpusMock).not.toHaveBeenCalled(); }); it("transcodes MiniMax MP3 to Opus for voice-note targets", async () => { const hexAudio = Buffer.from("fake-mp3-data").toString("hex"); const mockFetch = vi.mocked(globalThis.fetch); mockFetch.mockResolvedValueOnce( new Response(JSON.stringify({ data: { audio: hexAudio } }), { status: 200, headers: { "Content-Type": "application/json" }, }), ); transcodeAudioBufferToOpusMock.mockResolvedValueOnce(Buffer.from("fake-opus-data")); const result = await provider.synthesize({ text: "Hello world", cfg: {} as never, providerConfig: { apiKey: "sk-test", baseUrl: "https://api.minimaxi.com" }, target: "voice-note", timeoutMs: 30000, }); expect(result.outputFormat).toBe("opus"); expect(result.fileExtension).toBe(".opus"); expect(result.voiceCompatible).toBe(true); expect(result.audioBuffer.toString()).toBe("fake-opus-data"); expect(transcodeAudioBufferToOpusMock).toHaveBeenCalledWith({ audioBuffer: Buffer.from("fake-mp3-data"), inputExtension: "mp3", tempPrefix: "tts-minimax-", timeoutMs: 30000, }); }); it("applies overrides", async () => { const hexAudio = Buffer.from("audio").toString("hex"); const mockFetch = vi.mocked(globalThis.fetch); mockFetch.mockResolvedValueOnce( new Response(JSON.stringify({ data: { audio: hexAudio } }), { status: 200 }), ); await provider.synthesize({ text: "Test", cfg: {} as never, providerConfig: { apiKey: "sk-test" }, providerOverrides: { model: "speech-01-240228", voiceId: "custom_voice", speed: 1.5, vol: 1.5, pitch: 0.5, }, target: "audio-file", timeoutMs: 30000, }); const body = firstFetchBody(); expect(body.model).toBe("speech-01-240228"); const voiceSetting = body.voice_setting as Record; expect(voiceSetting.voice_id).toBe("custom_voice"); expect(voiceSetting.speed).toBe(1.5); expect(voiceSetting.vol).toBe(1.5); expect(voiceSetting.pitch).toBe(0); }); it("drops malformed voice settings before synthesis", async () => { const hexAudio = Buffer.from("audio").toString("hex"); const mockFetch = vi.mocked(globalThis.fetch); mockFetch.mockResolvedValueOnce( new Response(JSON.stringify({ data: { audio: hexAudio } }), { status: 200 }), ); await provider.synthesize({ text: "Test", cfg: {} as never, providerConfig: { apiKey: "sk-test", speed: 3, vol: -1, pitch: 20, }, target: "audio-file", timeoutMs: 30000, }); const voiceSetting = firstFetchBody().voice_setting as Record; expect(voiceSetting.speed).toBe(1); expect(voiceSetting.vol).toBe(1); expect(voiceSetting.pitch).toBe(0); }); it("uses a MiniMax Token Plan env var when no API key is configured", async () => { process.env.MINIMAX_CODING_API_KEY = "sk-cp-env"; const hexAudio = Buffer.from("audio").toString("hex"); vi.mocked(globalThis.fetch).mockResolvedValueOnce( new Response(JSON.stringify({ data: { audio: hexAudio } }), { status: 200 }), ); await provider.synthesize({ text: "Token plan TTS", cfg: {} as never, providerConfig: {}, target: "audio-file", timeoutMs: 30000, }); const init = firstFetchInit(); expect(init?.headers).toEqual({ Authorization: "Bearer sk-cp-env", "Content-Type": "application/json", }); }); it("uses a minimax-portal auth profile before env API keys", async () => { process.env.MINIMAX_API_KEY = "sk-env"; seedMinimaxPortalProfile(tempAgentDir); const hexAudio = Buffer.from("audio").toString("hex"); vi.mocked(globalThis.fetch).mockResolvedValueOnce( new Response(JSON.stringify({ data: { audio: hexAudio } }), { status: 200 }), ); await provider.synthesize({ text: "Portal TTS", cfg: { models: { providers: { "minimax-portal": { baseUrl: "https://api.minimaxi.com/anthropic" }, }, }, } as never, providerConfig: {}, target: "audio-file", timeoutMs: 30000, }); const url = firstFetchCall()[0]; const init = firstFetchInit(); expect(url).toBe("https://api.minimaxi.com/v1/t2a_v2"); expect(init?.headers).toEqual({ Authorization: "Bearer portal-token", "Content-Type": "application/json", }); }); it("throws when API key is missing", async () => { await expect( provider.synthesize({ text: "Test", cfg: {} as never, providerConfig: {}, target: "audio-file", timeoutMs: 30000, }), ).rejects.toThrow("MiniMax TTS auth missing"); }); it("throws on API error with response body", async () => { vi.mocked(globalThis.fetch).mockResolvedValueOnce( new Response("Unauthorized", { status: 401 }), ); await expect( provider.synthesize({ text: "Test", cfg: {} as never, providerConfig: { apiKey: "sk-test" }, target: "audio-file", timeoutMs: 30000, }), ).rejects.toThrow("MiniMax TTS API error (401): Unauthorized"); }); }); describe("listVoices", () => { it("returns known voices", async () => { const listVoices = provider.listVoices; if (!listVoices) { throw new Error("Expected MiniMax provider listVoices"); } const voices = await listVoices({} as never); expect(voices).toStrictEqual([ { id: "English_expressive_narrator", name: "English_expressive_narrator", }, { id: "Chinese (Mandarin)_Warm_Girl", name: "Chinese (Mandarin)_Warm_Girl", }, { id: "Chinese (Mandarin)_Lively_Girl", name: "Chinese (Mandarin)_Lively_Girl", }, { id: "Chinese (Mandarin)_Gentle_Boy", name: "Chinese (Mandarin)_Gentle_Boy", }, { id: "Chinese (Mandarin)_Steady_Boy", name: "Chinese (Mandarin)_Steady_Boy", }, ]); }); }); });