diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index ff96f92b8a..c85fde8fe4 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -118,7 +118,8 @@ "open-sse/executors/grok-web.ts": 1871, "open-sse/executors/muse-spark-web.ts": 1284, "open-sse/executors/perplexity-web.ts": 1013, - "open-sse/handlers/audioSpeech.ts": 965, + "_rebaseline_pr1043_minimax_tts": "Upstream port decolua/9router#1043 (toanalien) own growth: audioSpeech.ts 965->1061 (+96). Adds MiniMax T2A v2 TTS dispatch (handleMinimaxSpeech + hexToBytes helper) — provider entry was already in audioRegistry (format: minimax-tts) but no handler existed, falling through to the OpenAI-compatible default that fails (T2A has custom shape + hex-encoded audio + base_resp envelope). New branch sits next to the other inline provider branches (xiaomi-mimo, coqui, tortoise, aws-polly) — extracting would just create indirection. Covered by tests/unit/minimax-tts-1043.test.ts (3 tests, GREEN: success, base_resp error, invalid-hex).", + "open-sse/handlers/audioSpeech.ts": 1061, "open-sse/handlers/chatCore.ts": 5125, "open-sse/handlers/imageGeneration.ts": 3777, "open-sse/handlers/responseSanitizer.ts": 1103, @@ -235,7 +236,8 @@ "tests/unit/image-generation-handler.test.ts": 1996, "tests/unit/model-sync-route.test.ts": 1016, "tests/unit/models-catalog-route.test.ts": 1507, - "tests/unit/oauth-providers-config.test.ts": 855, + "_rebaseline_pr4561_qwen_oauth_url": "Reconcile #4561 (port decolua/9router#683) already-merged growth: oauth-providers-config.test.ts 855->867 (+12, qwen.ai URL regression-pin test). Fast-gate PR->release does not run check:file-size, so this surfaced post-merge.", + "tests/unit/oauth-providers-config.test.ts": 867, "tests/unit/perplexity-web.test.ts": 959, "tests/unit/provider-models-route.test.ts": 1618, "tests/unit/provider-validation-specialty.test.ts": 2752, diff --git a/open-sse/handlers/audioSpeech.ts b/open-sse/handlers/audioSpeech.ts index 1034349016..9bb280248c 100644 --- a/open-sse/handlers/audioSpeech.ts +++ b/open-sse/handlers/audioSpeech.ts @@ -771,6 +771,98 @@ async function handleXiaomiMimoSpeech(providerConfig, body, modelId, token, cred }); } +/** + * MiniMax T2A v2 — POST returns hex-encoded audio in a JSON envelope guarded by + * `base_resp.status_code` (0 = success). + * Port of decolua/9router#1043 by toanalien . + */ +function hexToBytes(audioHex) { + const clean = typeof audioHex === "string" ? audioHex.trim() : ""; + if (!clean) throw new Error("MiniMax TTS returned no audio"); + if (clean.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(clean)) { + throw new Error("MiniMax TTS returned invalid audio"); + } + const len = clean.length / 2; + const out = new Uint8Array(len); + for (let i = 0; i < len; i++) { + out[i] = parseInt(clean.substr(i * 2, 2), 16); + } + return out; +} + +async function handleMinimaxSpeech(providerConfig, body, modelId, token) { + const voiceId = + (typeof body.voice === "string" && body.voice) || "English_expressive_narrator"; + const res = await fetch(providerConfig.baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...buildAuthHeaders(providerConfig, token), + }, + body: JSON.stringify({ + model: modelId || "speech-2.8-hd", + text: body.input, + stream: false, + language_boost: "auto", + output_format: "hex", + voice_setting: { + voice_id: voiceId, + speed: typeof body.speed === "number" ? body.speed : 1, + vol: 1, + pitch: 0, + }, + audio_setting: { + sample_rate: 32000, + bitrate: 128000, + format: "mp3", + channel: 1, + }, + }), + }); + + const rawText = await res.text(); + let data: Record = {}; + if (rawText) { + try { + const parsed = JSON.parse(rawText); + if (parsed && typeof parsed === "object") data = parsed as Record; + } catch { + data = {}; + } + } + + if (!res.ok) { + return upstreamErrorResponse(res, rawText); + } + + const baseResp = + ((data.base_resp || data.baseResp) as Record | undefined) || {}; + const statusCode = Number(baseResp.status_code ?? baseResp.statusCode ?? 0); + const statusMessage = String( + baseResp.status_msg || baseResp.statusMsg || data.message || "" + ); + if (statusCode !== 0) { + return errorResponse(502, `MiniMax TTS: ${statusMessage || "upstream error"}`); + } + + const audioField = (data.data as Record | undefined)?.audio; + let bytes: Uint8Array; + try { + bytes = hexToBytes(audioField); + } catch (err) { + const msg = err instanceof Error ? err.message : "invalid audio"; + return errorResponse(502, `MiniMax TTS: ${msg}`); + } + + return new Response(bytes, { + status: 200, + headers: { + ...CORS_HEADERS, + "Content-Type": "audio/mpeg", + }, + }); +} + /** * Handle Coqui TTS (local, no auth) * POST {baseUrl} with { text, speaker_id } → WAV audio @@ -929,6 +1021,10 @@ export async function handleAudioSpeech({ return handleXiaomiMimoSpeech(providerConfig, body, modelId, token, credentials); } + if (providerConfig.format === "minimax-tts") { + return handleMinimaxSpeech(providerConfig, body, modelId, token); + } + if (providerConfig.format === "coqui") { return handleCoquiSpeech(providerConfig, body); } diff --git a/tests/unit/minimax-tts-1043.test.ts b/tests/unit/minimax-tts-1043.test.ts new file mode 100644 index 0000000000..08ec181aab --- /dev/null +++ b/tests/unit/minimax-tts-1043.test.ts @@ -0,0 +1,101 @@ +// Port of decolua/9router#1043 by toanalien +// MiniMax T2A v2 returns hex-encoded audio in a JSON envelope guarded by `base_resp`. +import test from "node:test"; +import assert from "node:assert/strict"; + +const { handleAudioSpeech } = await import("../../open-sse/handlers/audioSpeech.ts"); + +const TEXT = "hello minimax"; +const HEX_AUDIO = "deadbeefcafe1234"; // 8 bytes; base64 = "3q2+78r+EjQ=" + +test("handleAudioSpeech routes MiniMax format to T2A v2 with hex output", async () => { + const originalFetch = globalThis.fetch; + let captured: any; + + globalThis.fetch = async (url: any, options: any = {}) => { + captured = { + url: String(url), + headers: options.headers, + body: JSON.parse(String(options.body || "{}")), + }; + return new Response( + JSON.stringify({ + data: { audio: HEX_AUDIO }, + base_resp: { status_code: 0, status_msg: "success" }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + try { + const response = await handleAudioSpeech({ + body: { model: "minimax/speech-2.8-hd", input: TEXT, voice: "English_expressive_narrator" }, + credentials: { apiKey: "mm-key" }, + }); + + assert.equal(response.status, 200, "should 200 on success"); + assert.equal(captured.url, "https://api.minimax.io/v1/t2a_v2"); + assert.equal((captured.headers as any).Authorization, "Bearer mm-key"); + assert.equal(captured.body.model, "speech-2.8-hd"); + assert.equal(captured.body.text, TEXT); + assert.equal(captured.body.stream, false); + assert.equal(captured.body.output_format, "hex"); + assert.equal(captured.body.voice_setting.voice_id, "English_expressive_narrator"); + assert.ok(captured.body.audio_setting, "audio_setting present"); + + const ct = response.headers.get("content-type") || ""; + assert.ok(ct.startsWith("audio/"), `content-type should be audio/*, got ${ct}`); + + const buf = new Uint8Array(await response.arrayBuffer()); + assert.deepEqual( + Array.from(buf), + [0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0x12, 0x34], + "hex audio should be decoded to bytes" + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleAudioSpeech surfaces MiniMax base_resp error", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response( + JSON.stringify({ base_resp: { status_code: 2013, status_msg: "invalid voice" } }), + { status: 200, headers: { "content-type": "application/json" } } + ); + try { + const response = await handleAudioSpeech({ + body: { model: "minimax/speech-2.8-hd", input: TEXT }, + credentials: { apiKey: "mm-key" }, + }); + assert.notEqual(response.status, 200, "non-zero base_resp.status_code must not be 200"); + const payload = (await response.json()) as any; + assert.match(String(payload?.error?.message || ""), /invalid voice|MiniMax/i); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleAudioSpeech rejects invalid hex audio from MiniMax", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response( + JSON.stringify({ + data: { audio: "zzznot-hex" }, + base_resp: { status_code: 0, status_msg: "" }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + try { + const response = await handleAudioSpeech({ + body: { model: "minimax/speech-2.8-hd", input: TEXT }, + credentials: { apiKey: "mm-key" }, + }); + assert.notEqual(response.status, 200); + const payload = (await response.json()) as any; + assert.match(String(payload?.error?.message || ""), /invalid audio|MiniMax/i); + } finally { + globalThis.fetch = originalFetch; + } +});