Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
fix(speech): bound TTS response reads
  • Loading branch information
Alix-007 committed Jun 26, 2026
commit 2774988dea5bc4735b25cd57afc43efd5e05004f
49 changes: 49 additions & 0 deletions extensions/volcengine/tts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}));
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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({
Expand All @@ -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);
});
});
18 changes: 16 additions & 2 deletions extensions/volcengine/tts.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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"}`,
Expand Down
32 changes: 32 additions & 0 deletions extensions/xiaomi/speech-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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");
});
});
});
8 changes: 6 additions & 2 deletions extensions/xiaomi/speech-provider.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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();
}
Expand Down
Loading