mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 23:02:10 +03:00
Integrated into release/v3.8.34 (rebuilt onto tip)
This commit is contained in:
committed by
GitHub
parent
fc3b417f40
commit
d6caf6ed39
@@ -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,
|
||||
|
||||
@@ -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 <toanalien@gmail.com>.
|
||||
*/
|
||||
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<string, unknown> = {};
|
||||
if (rawText) {
|
||||
try {
|
||||
const parsed = JSON.parse(rawText);
|
||||
if (parsed && typeof parsed === "object") data = parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
data = {};
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
return upstreamErrorResponse(res, rawText);
|
||||
}
|
||||
|
||||
const baseResp =
|
||||
((data.base_resp || data.baseResp) as Record<string, unknown> | 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<string, unknown> | 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);
|
||||
}
|
||||
|
||||
101
tests/unit/minimax-tts-1043.test.ts
Normal file
101
tests/unit/minimax-tts-1043.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
// Port of decolua/9router#1043 by toanalien <toanalien@gmail.com>
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user