Skip to content

Commit 4438be7

Browse files
committed
fix(tts): bound generated speech downloads
1 parent c4a5bba commit 4438be7

12 files changed

Lines changed: 239 additions & 5 deletions

extensions/gradium/speech-provider.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,29 @@ describe("gradium speech provider", () => {
9898
expect(result.audioBuffer).toEqual(audioData);
9999
});
100100

101+
it("applies the configured media byte cap to synthesized audio", async () => {
102+
vi.stubGlobal(
103+
"fetch",
104+
vi.fn().mockResolvedValue(new Response(new Uint8Array(2048), { status: 200 })),
105+
);
106+
107+
await expect(
108+
provider.synthesize({
109+
text: "OpenClaw test",
110+
cfg: {
111+
agents: {
112+
defaults: {
113+
mediaMaxMb: 0.001,
114+
},
115+
},
116+
} as never,
117+
providerConfig: { apiKey: "gsk_test123" },
118+
target: "audio-file",
119+
timeoutMs: 30_000,
120+
}),
121+
).rejects.toThrow("Gradium TTS audio response exceeds");
122+
});
123+
101124
it("uses ulaw_8000 for telephony synthesis", async () => {
102125
const audioData = Buffer.from("ulaw-audio-data");
103126
const fetchMock = vi.fn().mockResolvedValue(new Response(audioData, { status: 200 }));

extensions/gradium/speech-provider.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { asObject, trimToUndefined } from "openclaw/plugin-sdk/speech";
88
import { DEFAULT_GRADIUM_VOICE_ID, GRADIUM_VOICES, normalizeGradiumBaseUrl } from "./shared.js";
99
import { gradiumTTS } from "./tts.js";
1010

11+
const DEFAULT_GENERATED_AUDIO_MAX_BYTES = 16 * 1024 * 1024;
12+
1113
type GradiumProviderConfig = {
1214
apiKey?: string;
1315
baseUrl: string;
@@ -36,6 +38,16 @@ function readGradiumProviderConfig(config: SpeechProviderConfig): GradiumProvide
3638
};
3739
}
3840

41+
function resolveGeneratedAudioMaxBytes(req: {
42+
cfg: { agents?: { defaults?: { mediaMaxMb?: number } } };
43+
}): number {
44+
const configured = req.cfg.agents?.defaults?.mediaMaxMb;
45+
if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) {
46+
return Math.floor(configured * 1024 * 1024);
47+
}
48+
return DEFAULT_GENERATED_AUDIO_MAX_BYTES;
49+
}
50+
3951
function parseDirectiveToken(ctx: SpeechDirectiveTokenParseContext): {
4052
handled: boolean;
4153
overrides?: Record<string, unknown>;
@@ -86,6 +98,7 @@ export function buildGradiumSpeechProvider(): SpeechProviderPlugin {
8698
voiceId: trimToUndefined(overrides.voiceId) ?? config.voiceId,
8799
outputFormat,
88100
timeoutMs: req.timeoutMs,
101+
maxBytes: resolveGeneratedAudioMaxBytes(req),
89102
});
90103
return {
91104
audioBuffer,
@@ -110,6 +123,7 @@ export function buildGradiumSpeechProvider(): SpeechProviderPlugin {
110123
voiceId: trimToUndefined(overrides.voiceId) ?? config.voiceId,
111124
outputFormat,
112125
timeoutMs: req.timeoutMs,
126+
maxBytes: resolveGeneratedAudioMaxBytes(req),
113127
});
114128
return { audioBuffer, outputFormat, sampleRate };
115129
},

extensions/gradium/tts.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ describe("gradium tts diagnostics", () => {
2828
};
2929
}
3030

31+
function createStreamingAudioResponse(params: {
32+
chunkCount: number;
33+
chunkSize: number;
34+
byte: number;
35+
}): { response: Response; getReadCount: () => number } {
36+
return createStreamingErrorResponse({ ...params, status: 200 });
37+
}
38+
3139
afterEach(() => {
3240
vi.unstubAllGlobals();
3341
vi.restoreAllMocks();
@@ -134,4 +142,27 @@ describe("gradium tts diagnostics", () => {
134142
});
135143
expect(result).toEqual(audioData);
136144
});
145+
146+
it("caps streamed audio responses instead of buffering oversized TTS output", async () => {
147+
const streamed = createStreamingAudioResponse({
148+
chunkCount: 20,
149+
chunkSize: 1024,
150+
byte: 121,
151+
});
152+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(streamed.response));
153+
154+
await expect(
155+
gradiumTTS({
156+
text: "hello",
157+
apiKey: "test-key",
158+
baseUrl: "https://api.gradium.ai",
159+
voiceId: "YTpq7expH9539ERJ",
160+
outputFormat: "wav",
161+
timeoutMs: 5_000,
162+
maxBytes: 2048,
163+
}),
164+
).rejects.toThrow("Gradium TTS audio response exceeds 2048 bytes");
165+
166+
expect(streamed.getReadCount()).toBeLessThan(20);
167+
});
137168
});

extensions/gradium/tts.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,28 @@
11
import { assertOkOrThrowProviderError } from "openclaw/plugin-sdk/provider-http";
2+
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
23
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
34
import { normalizeGradiumBaseUrl } from "./shared.js";
45

6+
const DEFAULT_TTS_MAX_BYTES = 16 * 1024 * 1024;
7+
58
export async function gradiumTTS(params: {
69
text: string;
710
apiKey: string;
811
baseUrl: string;
912
voiceId: string;
1013
outputFormat: "wav" | "opus" | "ulaw_8000" | "pcm" | "pcm_24000" | "alaw_8000";
1114
timeoutMs: number;
15+
maxBytes?: number;
1216
}): Promise<Buffer> {
13-
const { text, apiKey, baseUrl, voiceId, outputFormat, timeoutMs } = params;
17+
const {
18+
text,
19+
apiKey,
20+
baseUrl,
21+
voiceId,
22+
outputFormat,
23+
timeoutMs,
24+
maxBytes = DEFAULT_TTS_MAX_BYTES,
25+
} = params;
1426
const normalizedBaseUrl = normalizeGradiumBaseUrl(baseUrl);
1527
const url = `${normalizedBaseUrl}/api/post/speech/tts`;
1628
const hostname = new URL(normalizedBaseUrl).hostname;
@@ -39,7 +51,10 @@ export async function gradiumTTS(params: {
3951
try {
4052
await assertOkOrThrowProviderError(response, "Gradium API error");
4153

42-
return Buffer.from(await response.arrayBuffer());
54+
return await readResponseWithLimit(response, maxBytes, {
55+
onOverflow: ({ maxBytes }) =>
56+
new Error(`Gradium TTS audio response exceeds ${maxBytes} bytes`),
57+
});
4358
} finally {
4459
await release();
4560
}

extensions/openai/speech-provider.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,33 @@ describe("buildOpenAISpeechProvider", () => {
291291
expect(result.voiceCompatible).toBe(false);
292292
});
293293

294+
it("applies the configured media byte cap to synthesized audio", async () => {
295+
const provider = buildOpenAISpeechProvider();
296+
globalThis.fetch = vi.fn(
297+
async () => new Response(new Uint8Array(2048), { status: 200 }),
298+
) as unknown as typeof fetch;
299+
300+
await expect(
301+
provider.synthesize({
302+
text: "hello",
303+
cfg: {
304+
agents: {
305+
defaults: {
306+
mediaMaxMb: 0.001,
307+
},
308+
},
309+
} as never,
310+
providerConfig: {
311+
apiKey: "sk-test",
312+
model: "gpt-4o-mini-tts",
313+
voice: "alloy",
314+
},
315+
target: "audio-file",
316+
timeoutMs: 1_000,
317+
}),
318+
).rejects.toThrow("OpenAI TTS audio response exceeds");
319+
});
320+
294321
it("applies provider overrides to telephony synthesis", async () => {
295322
const provider = buildOpenAISpeechProvider();
296323
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {

extensions/openai/speech-provider.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
} from "./tts.js";
2727

2828
const OPENAI_SPEECH_RESPONSE_FORMATS = ["mp3", "opus", "wav"] as const;
29+
const DEFAULT_GENERATED_AUDIO_MAX_BYTES = 16 * 1024 * 1024;
2930

3031
type OpenAiSpeechResponseFormat = (typeof OPENAI_SPEECH_RESPONSE_FORMATS)[number];
3132

@@ -174,6 +175,16 @@ function readOpenAIOverrides(
174175
};
175176
}
176177

178+
function resolveGeneratedAudioMaxBytes(req: {
179+
cfg: { agents?: { defaults?: { mediaMaxMb?: number } } };
180+
}): number {
181+
const configured = req.cfg.agents?.defaults?.mediaMaxMb;
182+
if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) {
183+
return Math.floor(configured * 1024 * 1024);
184+
}
185+
return DEFAULT_GENERATED_AUDIO_MAX_BYTES;
186+
}
187+
177188
function renderOpenAITtsPersonaInstructions(req: {
178189
label?: string;
179190
prompt?: {
@@ -328,6 +339,7 @@ export function buildOpenAISpeechProvider(): SpeechProviderPlugin {
328339
responseFormat,
329340
extraBody: config.extraBody,
330341
timeoutMs: req.timeoutMs,
342+
maxBytes: resolveGeneratedAudioMaxBytes(req),
331343
});
332344
return {
333345
audioBuffer,
@@ -356,6 +368,7 @@ export function buildOpenAISpeechProvider(): SpeechProviderPlugin {
356368
responseFormat: outputFormat,
357369
extraBody: config.extraBody,
358370
timeoutMs: req.timeoutMs,
371+
maxBytes: resolveGeneratedAudioMaxBytes(req),
359372
});
360373
return { audioBuffer, outputFormat, sampleRate };
361374
},

extensions/openai/tts.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,32 @@ describe("openai tts", () => {
322322
).rejects.toThrow("OpenAI TTS API error (503): temporary upstream outage");
323323
});
324324

325+
it("caps streamed audio responses instead of buffering oversized TTS output", async () => {
326+
const streamed = createStreamingErrorResponse({
327+
status: 200,
328+
chunkCount: 20,
329+
chunkSize: 1024,
330+
byte: 121,
331+
});
332+
const fetchMock = vi.fn(async () => streamed.response);
333+
globalThis.fetch = fetchMock as unknown as typeof fetch;
334+
335+
await expect(
336+
openaiTTS({
337+
text: "hello",
338+
apiKey: "test-key",
339+
baseUrl: "https://api.openai.com/v1",
340+
model: "gpt-4o-mini-tts",
341+
voice: "alloy",
342+
responseFormat: "mp3",
343+
timeoutMs: 5_000,
344+
maxBytes: 2048,
345+
}),
346+
).rejects.toThrow("OpenAI TTS audio response exceeds 2048 bytes");
347+
348+
expect(streamed.getReadCount()).toBeLessThan(20);
349+
});
350+
325351
it("caps streamed non-JSON error reads instead of consuming full response bodies", async () => {
326352
const streamed = createStreamingErrorResponse({
327353
status: 503,

extensions/openai/tts.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ import {
66
captureHttpExchange,
77
isDebugProxyGlobalFetchPatchInstalled,
88
} from "openclaw/plugin-sdk/proxy-capture";
9+
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
910
import {
1011
fetchWithSsrFGuard,
1112
ssrfPolicyFromHttpBaseUrlAllowedHostname,
1213
} from "openclaw/plugin-sdk/ssrf-runtime";
1314

1415
export const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
16+
const DEFAULT_TTS_MAX_BYTES = 16 * 1024 * 1024;
1517

1618
export const OPENAI_TTS_MODELS = ["gpt-4o-mini-tts", "tts-1", "tts-1-hd"] as const;
1719

@@ -100,6 +102,7 @@ export async function openaiTTS(params: {
100102
responseFormat: "mp3" | "opus" | "pcm" | "wav";
101103
extraBody?: Record<string, unknown>;
102104
timeoutMs: number;
105+
maxBytes?: number;
103106
}): Promise<Buffer> {
104107
const {
105108
text,
@@ -112,6 +115,7 @@ export async function openaiTTS(params: {
112115
responseFormat,
113116
extraBody,
114117
timeoutMs,
118+
maxBytes = DEFAULT_TTS_MAX_BYTES,
115119
} = params;
116120
const effectiveInstructions = resolveOpenAITtsInstructions(model, instructions, baseUrl);
117121

@@ -177,7 +181,10 @@ export async function openaiTTS(params: {
177181

178182
await assertOkOrThrowProviderError(response, "OpenAI TTS API error");
179183

180-
return Buffer.from(await response.arrayBuffer());
184+
return await readResponseWithLimit(response, maxBytes, {
185+
onOverflow: ({ maxBytes }) =>
186+
new Error(`OpenAI TTS audio response exceeds ${maxBytes} bytes`),
187+
});
181188
} finally {
182189
await release();
183190
}

extensions/xai/speech-provider.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ function requireLastTtsCall(): {
3737
language?: string;
3838
speed?: number;
3939
responseFormat?: string;
40+
maxBytes?: number;
4041
} {
4142
const params = (xaiTTSMock.mock.calls as unknown as Array<[unknown]>).at(-1)?.[0] as
4243
| {
@@ -47,6 +48,7 @@ function requireLastTtsCall(): {
4748
language?: string;
4849
speed?: number;
4950
responseFormat?: string;
51+
maxBytes?: number;
5052
}
5153
| undefined;
5254
if (!params) {
@@ -68,7 +70,13 @@ describe("xai speech provider", () => {
6870
const provider = buildXaiSpeechProvider();
6971
const result = await provider.synthesize({
7072
text: "hello",
71-
cfg: {},
73+
cfg: {
74+
agents: {
75+
defaults: {
76+
mediaMaxMb: 2,
77+
},
78+
},
79+
},
7280
providerConfig: {
7381
apiKey: "xai-key",
7482
voiceId: "eve",
@@ -87,6 +95,7 @@ describe("xai speech provider", () => {
8795
expect(tts.baseUrl).toBe("https://api.x.ai/v1");
8896
expect(tts.voiceId).toBe("eve");
8997
expect(tts.responseFormat).toBe("mp3");
98+
expect(tts.maxBytes).toBe(2 * 1024 * 1024);
9099
});
91100

92101
it("honors configured response formats", async () => {

extensions/xai/speech-provider.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
} from "./tts.js";
2727

2828
const XAI_SPEECH_RESPONSE_FORMATS = ["mp3", "wav", "pcm", "mulaw", "alaw"] as const;
29+
const DEFAULT_GENERATED_AUDIO_MAX_BYTES = 16 * 1024 * 1024;
2930

3031
type XaiSpeechResponseFormat = (typeof XAI_SPEECH_RESPONSE_FORMATS)[number];
3132

@@ -130,6 +131,16 @@ function readXaiOverrides(overrides: SpeechProviderOverrides | undefined): XaiTt
130131
};
131132
}
132133

134+
function resolveGeneratedAudioMaxBytes(req: {
135+
cfg: { agents?: { defaults?: { mediaMaxMb?: number } } };
136+
}): number {
137+
const configured = req.cfg.agents?.defaults?.mediaMaxMb;
138+
if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) {
139+
return Math.floor(configured * 1024 * 1024);
140+
}
141+
return DEFAULT_GENERATED_AUDIO_MAX_BYTES;
142+
}
143+
133144
function parseDirectiveToken(ctx: SpeechDirectiveTokenParseContext): {
134145
handled: boolean;
135146
overrides?: SpeechProviderOverrides;
@@ -231,6 +242,7 @@ export function buildXaiSpeechProvider(): SpeechProviderPlugin {
231242
speed: overrides.speed ?? config.speed,
232243
responseFormat,
233244
timeoutMs: req.timeoutMs,
245+
maxBytes: resolveGeneratedAudioMaxBytes(req),
234246
});
235247
return {
236248
audioBuffer,
@@ -254,6 +266,7 @@ export function buildXaiSpeechProvider(): SpeechProviderPlugin {
254266
speed: overrides.speed ?? config.speed,
255267
responseFormat: outputFormat,
256268
timeoutMs: req.timeoutMs,
269+
maxBytes: resolveGeneratedAudioMaxBytes(req),
257270
});
258271
return { audioBuffer, outputFormat, sampleRate };
259272
},

0 commit comments

Comments
 (0)