mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-17 12:21:45 +00:00
* fix(microsoft): add timeout to voices list request * fix(tts): bound voice-list requests Co-authored-by: llagy009 <0668001470@xydigit.com> --------- Co-authored-by: llagy009 <0668001470@xydigit.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
281 lines
8.7 KiB
TypeScript
281 lines
8.7 KiB
TypeScript
// Elevenlabs tests cover speech provider plugin behavior.
|
|
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
|
import { isValidElevenLabsVoiceId } from "./shared.js";
|
|
import { buildElevenLabsSpeechProvider } from "./speech-provider.js";
|
|
|
|
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
|
fetchWithSsrFGuard: async (params: {
|
|
url: string;
|
|
init?: RequestInit;
|
|
timeoutMs?: number;
|
|
}): Promise<{ response: Response; release: () => Promise<void> }> => {
|
|
fetchWithSsrFGuardMock(params);
|
|
return {
|
|
response: await globalThis.fetch(params.url, params.init),
|
|
release: vi.fn(async () => {}),
|
|
};
|
|
},
|
|
ssrfPolicyFromHttpBaseUrlAllowedHostname: () => undefined,
|
|
}));
|
|
|
|
function parseRequestBody(init: RequestInit | undefined): Record<string, unknown> {
|
|
if (typeof init?.body !== "string") {
|
|
throw new Error("expected string request body");
|
|
}
|
|
const body: unknown = JSON.parse(init.body);
|
|
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
throw new Error("expected ElevenLabs request body");
|
|
}
|
|
return body as Record<string, unknown>;
|
|
}
|
|
|
|
describe("elevenlabs speech provider", () => {
|
|
const originalFetch = globalThis.fetch;
|
|
|
|
afterAll(() => {
|
|
vi.doUnmock("openclaw/plugin-sdk/ssrf-runtime");
|
|
vi.resetModules();
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
fetchWithSsrFGuardMock.mockClear();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("exposes the current ElevenLabs TTS model catalog", () => {
|
|
const provider = buildElevenLabsSpeechProvider();
|
|
|
|
expect(provider.models).toEqual([
|
|
"eleven_v3",
|
|
"eleven_multilingual_v2",
|
|
"eleven_flash_v2_5",
|
|
"eleven_flash_v2",
|
|
"eleven_turbo_v2_5",
|
|
"eleven_monolingual_v1",
|
|
]);
|
|
});
|
|
|
|
it("forwards the core-resolved voice-list timeout", async () => {
|
|
globalThis.fetch = vi.fn(async () => Response.json({ voices: [] })) as unknown as typeof fetch;
|
|
const provider = buildElevenLabsSpeechProvider();
|
|
|
|
await provider.listVoices?.({
|
|
providerConfig: { apiKey: "xi-test" },
|
|
timeoutMs: 30_000,
|
|
});
|
|
|
|
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({ timeoutMs: 30_000 }),
|
|
);
|
|
});
|
|
|
|
it("keeps non-equivalent deprecated ElevenLabs TTS model IDs", async () => {
|
|
const provider = buildElevenLabsSpeechProvider();
|
|
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
|
const body = parseRequestBody(init);
|
|
expect(body.model_id).toBe("eleven_monolingual_v1");
|
|
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
|
|
});
|
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
await provider.synthesizeTelephony?.({
|
|
text: "hello",
|
|
cfg: {} as never,
|
|
providerConfig: {
|
|
apiKey: "xi-test",
|
|
modelId: "eleven_monolingual_v1",
|
|
},
|
|
timeoutMs: 1_000,
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("maps deprecated ElevenLabs TTS model IDs in overrides", async () => {
|
|
const provider = buildElevenLabsSpeechProvider();
|
|
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
|
const body = parseRequestBody(init);
|
|
expect(body.model_id).toBe("eleven_flash_v2_5");
|
|
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
|
|
});
|
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
await provider.synthesizeTelephony?.({
|
|
text: "hello",
|
|
cfg: {} as never,
|
|
providerConfig: {
|
|
apiKey: "xi-test",
|
|
modelId: "eleven_multilingual_v2",
|
|
},
|
|
providerOverrides: {
|
|
modelId: "eleven_turbo_v2_5",
|
|
},
|
|
timeoutMs: 1_000,
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("validates ElevenLabs voice ID length and character rules", () => {
|
|
const cases = [
|
|
{ value: "pMsXgVXv3BLzUgSXRplE", expected: true },
|
|
{ value: "21m00Tcm4TlvDq8ikWAM", expected: true },
|
|
{ value: "VoiceAlias1234567890", expected: true },
|
|
{ value: "a1b2c3d4e5", expected: true },
|
|
{ value: "a".repeat(40), expected: true },
|
|
{ value: "", expected: false },
|
|
{ value: "abc", expected: false },
|
|
{ value: "123456789", expected: false },
|
|
{ value: "a".repeat(41), expected: false },
|
|
{ value: "a".repeat(100), expected: false },
|
|
{ value: "pMsXgVXv3BLz-gSXRplE", expected: false },
|
|
{ value: "pMsXgVXv3BLz_gSXRplE", expected: false },
|
|
{ value: "pMsXgVXv3BLz gSXRplE", expected: false },
|
|
{ value: "../../../etc/passwd", expected: false },
|
|
{ value: "voice?param=value", expected: false },
|
|
] as const;
|
|
for (const testCase of cases) {
|
|
expect(isValidElevenLabsVoiceId(testCase.value), testCase.value).toBe(testCase.expected);
|
|
}
|
|
});
|
|
|
|
it("applies provider overrides to telephony synthesis", async () => {
|
|
const provider = buildElevenLabsSpeechProvider();
|
|
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
|
expect(url).toContain("/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM");
|
|
expect(url).toContain("output_format=pcm_22050");
|
|
const body = parseRequestBody(init);
|
|
expect(body).toEqual({
|
|
text: "hello",
|
|
model_id: "eleven_v3",
|
|
seed: 123,
|
|
apply_text_normalization: "on",
|
|
language_code: "en",
|
|
voice_settings: {
|
|
stability: 0.5,
|
|
similarity_boost: 0.75,
|
|
style: 0,
|
|
use_speaker_boost: true,
|
|
speed: 1.2,
|
|
},
|
|
});
|
|
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
|
|
});
|
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
const result = await provider.synthesizeTelephony?.({
|
|
text: "hello",
|
|
cfg: {} as never,
|
|
providerConfig: {
|
|
apiKey: "xi-test",
|
|
voiceId: "pMsXgVXv3BLzUgSXRplE",
|
|
modelId: "eleven_multilingual_v2",
|
|
},
|
|
providerOverrides: {
|
|
voiceId: "21m00Tcm4TlvDq8ikWAM",
|
|
modelId: "eleven_v3",
|
|
seed: 123,
|
|
applyTextNormalization: "on",
|
|
languageCode: "en",
|
|
voiceSettings: {
|
|
speed: 1.2,
|
|
},
|
|
},
|
|
timeoutMs: 1_000,
|
|
});
|
|
|
|
expect(result?.outputFormat).toBe("pcm_22050");
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("drops out-of-range voice settings before synthesis", async () => {
|
|
const provider = buildElevenLabsSpeechProvider();
|
|
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
|
const body = parseRequestBody(init);
|
|
expect(body.voice_settings).toEqual({
|
|
stability: 0.5,
|
|
similarity_boost: 0.75,
|
|
style: 0,
|
|
use_speaker_boost: true,
|
|
speed: 1,
|
|
});
|
|
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
|
|
});
|
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
await provider.synthesizeTelephony?.({
|
|
text: "hello",
|
|
cfg: {} as never,
|
|
providerConfig: {
|
|
apiKey: "xi-test",
|
|
voiceSettings: {
|
|
stability: -1,
|
|
similarityBoost: 2,
|
|
style: Number.NaN,
|
|
speed: 3,
|
|
},
|
|
},
|
|
providerOverrides: {
|
|
voiceSettings: {
|
|
speed: 0.1,
|
|
},
|
|
},
|
|
timeoutMs: 1_000,
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("drops malformed seed values before synthesis", async () => {
|
|
const provider = buildElevenLabsSpeechProvider();
|
|
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
|
const body = parseRequestBody(init);
|
|
expect(body).not.toHaveProperty("seed");
|
|
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
|
|
});
|
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
await provider.synthesizeTelephony?.({
|
|
text: "hello",
|
|
cfg: {} as never,
|
|
providerConfig: {
|
|
apiKey: "xi-test",
|
|
seed: 1.5,
|
|
},
|
|
providerOverrides: {
|
|
seed: Number.POSITIVE_INFINITY,
|
|
},
|
|
timeoutMs: 1_000,
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("drops malformed latency tier overrides before synthesis", async () => {
|
|
const provider = buildElevenLabsSpeechProvider();
|
|
const fetchMock = vi.fn(async (url: string) => {
|
|
expect(new URL(url).searchParams.has("optimize_streaming_latency")).toBe(false);
|
|
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
|
|
});
|
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
await provider.synthesize?.({
|
|
text: "hello",
|
|
target: "audio-file",
|
|
cfg: {} as never,
|
|
providerConfig: {
|
|
apiKey: "xi-test",
|
|
},
|
|
providerOverrides: {
|
|
latencyTier: 2.5,
|
|
},
|
|
timeoutMs: 1_000,
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|