mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
fix(speech): bound TTS response reads (#96874)
(cherry picked from commit 2f851ecfe9)
This commit is contained in:
@@ -7,6 +7,8 @@ const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
|
||||
fetchWithSsrFGuardMock: vi.fn(),
|
||||
}));
|
||||
|
||||
const PROVIDER_RESPONSE_MAX_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
||||
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
|
||||
}));
|
||||
@@ -45,6 +47,18 @@ function clearTtsEnv() {
|
||||
delete process.env.VOLCENGINE_TTS_TOKEN;
|
||||
}
|
||||
|
||||
function makeOversizedStreamResponse(): Response {
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(PROVIDER_RESPONSE_MAX_BYTES));
|
||||
controller.enqueue(new Uint8Array(1));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function restoreOptionalEnv(key: string, value: string | undefined) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
@@ -301,6 +315,23 @@ describe("volcengineTTS", () => {
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("bounds Seed Speech success response reads", async () => {
|
||||
const release = vi.fn();
|
||||
fetchWithSsrFGuardMock.mockResolvedValue({
|
||||
response: makeOversizedStreamResponse(),
|
||||
release,
|
||||
});
|
||||
|
||||
await expect(
|
||||
volcengineTTS({
|
||||
text: "hello",
|
||||
apiKey: "secret-api-key",
|
||||
timeoutMs: 1000,
|
||||
}),
|
||||
).rejects.toThrow("BytePlus Seed Speech TTS response exceeds 16777216 bytes");
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports provider errors without exposing credentials", async () => {
|
||||
const release = vi.fn();
|
||||
fetchWithSsrFGuardMock.mockResolvedValue({
|
||||
@@ -327,4 +358,22 @@ describe("volcengineTTS", () => {
|
||||
expect((error as Error).message).not.toContain("secret-token");
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("bounds legacy Volcengine success response reads", async () => {
|
||||
const release = vi.fn();
|
||||
fetchWithSsrFGuardMock.mockResolvedValue({
|
||||
response: makeOversizedStreamResponse(),
|
||||
release,
|
||||
});
|
||||
|
||||
await expect(
|
||||
volcengineTTS({
|
||||
text: "hello",
|
||||
appId: "app-id",
|
||||
token: "secret-token",
|
||||
timeoutMs: 1000,
|
||||
}),
|
||||
).rejects.toThrow("Volcengine TTS response exceeds 16777216 bytes");
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Volcengine plugin module implements tts behavior.
|
||||
import * as crypto from "node:crypto";
|
||||
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
|
||||
export type VolcengineTtsEncoding = "ogg_opus" | "mp3" | "pcm" | "wav";
|
||||
@@ -27,6 +28,7 @@ const DEFAULT_LEGACY_VOICE = "zh_female_xiaohe_uranus_bigtts";
|
||||
const DEFAULT_CLUSTER = "volcano_tts";
|
||||
const DEFAULT_SEED_TTS_RESOURCE_ID = "seed-tts-1.0";
|
||||
const DEFAULT_SEED_TTS_APP_KEY = "aGjiRDfUWi";
|
||||
const VOLCENGINE_TTS_RESPONSE_MAX_BYTES = 16 * 1024 * 1024;
|
||||
const BYTEPLUS_SEED_TTS_URL =
|
||||
"https://voice.ap-southeast-1.bytepluses.com/api/v3/tts/unidirectional";
|
||||
const VOLCENGINE_LEGACY_TTS_URL = "https://openspeech.bytedance.com/api/v1/tts";
|
||||
@@ -158,7 +160,13 @@ async function seedSpeechTTS(params: VolcengineTTSParams & { apiKey: string }):
|
||||
});
|
||||
|
||||
try {
|
||||
const frames = parseSeedTtsFrames(await response.text());
|
||||
const responseText = new TextDecoder().decode(
|
||||
await readResponseWithLimit(response, VOLCENGINE_TTS_RESPONSE_MAX_BYTES, {
|
||||
onOverflow: ({ maxBytes }) =>
|
||||
new Error(`BytePlus Seed Speech TTS response exceeds ${maxBytes} bytes`),
|
||||
}),
|
||||
);
|
||||
const frames = parseSeedTtsFrames(responseText);
|
||||
const chunks: Buffer[] = [];
|
||||
for (const frame of frames) {
|
||||
if (frame.code === 0) {
|
||||
@@ -240,7 +248,13 @@ async function legacyVolcengineTTS(
|
||||
});
|
||||
|
||||
try {
|
||||
const body = parseLegacyTtsResponse(await response.text());
|
||||
const responseText = new TextDecoder().decode(
|
||||
await readResponseWithLimit(response, VOLCENGINE_TTS_RESPONSE_MAX_BYTES, {
|
||||
onOverflow: ({ maxBytes }) =>
|
||||
new Error(`Volcengine TTS response exceeds ${maxBytes} bytes`),
|
||||
}),
|
||||
);
|
||||
const body = parseLegacyTtsResponse(responseText);
|
||||
if (!response.ok || body.code !== 3000 || !body.data) {
|
||||
throw new Error(
|
||||
`Volcengine TTS error ${body.code ?? response.status}: ${body.message ?? "unknown"}`,
|
||||
|
||||
@@ -4,12 +4,30 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const transcodeAudioBufferToOpusMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
const PROVIDER_RESPONSE_MAX_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/media-runtime", () => ({
|
||||
transcodeAudioBufferToOpus: transcodeAudioBufferToOpusMock,
|
||||
}));
|
||||
|
||||
import { buildXiaomiSpeechProvider } from "./speech-provider.js";
|
||||
|
||||
function makeOversizedStreamResponse(): Response {
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(PROVIDER_RESPONSE_MAX_BYTES));
|
||||
controller.enqueue(new Uint8Array(1));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe("buildXiaomiSpeechProvider", () => {
|
||||
const provider = buildXiaomiSpeechProvider();
|
||||
|
||||
@@ -410,5 +428,19 @@ describe("buildXiaomiSpeechProvider", () => {
|
||||
}),
|
||||
).rejects.toThrow("Xiaomi TTS API returned no audio data");
|
||||
});
|
||||
|
||||
it("bounds oversized Xiaomi TTS success response reads", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(makeOversizedStreamResponse());
|
||||
|
||||
await expect(
|
||||
provider.synthesize({
|
||||
text: "Test",
|
||||
cfg: {} as never,
|
||||
providerConfig: { apiKey: "sk-test" },
|
||||
target: "audio-file",
|
||||
timeoutMs: 30000,
|
||||
}),
|
||||
).rejects.toThrow("Xiaomi TTS API: JSON response exceeds 16777216 bytes");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// Xiaomi provider module implements model/runtime integration.
|
||||
import { transcodeAudioBufferToOpus } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { assertOkOrThrowProviderError } from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
assertOkOrThrowProviderError,
|
||||
readProviderJsonResponse,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
|
||||
import type {
|
||||
SpeechDirectiveTokenParseContext,
|
||||
@@ -269,7 +272,8 @@ async function xiaomiTTS(params: {
|
||||
});
|
||||
try {
|
||||
await assertOkOrThrowProviderError(response, "Xiaomi TTS API error");
|
||||
return decodeXiaomiAudioData(await response.json());
|
||||
const body = await readProviderJsonResponse<unknown>(response, "Xiaomi TTS API");
|
||||
return decodeXiaomiAudioData(body);
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user