Skip to content

Commit 9890e08

Browse files
mushuiyu886vincentkoc
authored andcommitted
fix(google): bound TTS success JSON response reads
1 parent a7bfc06 commit 9890e08

3 files changed

Lines changed: 105 additions & 1 deletion

File tree

extensions/google/speech-provider.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ const {
2020
let buildGoogleSpeechProvider: typeof import("./speech-provider.js").buildGoogleSpeechProvider;
2121
let testing: typeof import("./speech-provider.js").testing;
2222

23+
const GOOGLE_TTS_JSON_CAP_BYTES = 16 * 1024 * 1024;
24+
2325
beforeAll(async () => {
2426
({ buildGoogleSpeechProvider, testing } = await import("./speech-provider.js"));
2527
});
@@ -56,6 +58,26 @@ function installGoogleTtsRequestMock(pcm = Buffer.from([1, 0, 2, 0])) {
5658
return postJsonRequestMock;
5759
}
5860

61+
function oversizedGoogleTtsJsonResponse(onCancel: () => void): Response {
62+
const response = new Response(
63+
new ReadableStream<Uint8Array>({
64+
start(controller) {
65+
controller.enqueue(new Uint8Array(GOOGLE_TTS_JSON_CAP_BYTES + 1));
66+
},
67+
cancel() {
68+
onCancel();
69+
},
70+
}),
71+
{ headers: { "content-type": "application/json" }, status: 200 },
72+
);
73+
Object.defineProperty(response, "json", {
74+
value: async () => {
75+
throw new Error("unbounded json reader was used");
76+
},
77+
});
78+
return response;
79+
}
80+
5981
function expectRecordFields(value: unknown, expected: Record<string, unknown>) {
6082
if (!value || typeof value !== "object") {
6183
throw new Error("Expected record");
@@ -149,6 +171,39 @@ describe("Google speech provider", () => {
149171
expect(transcodeAudioBufferToOpusMock).not.toHaveBeenCalled();
150172
});
151173

174+
it("bounds oversized Gemini TTS success JSON responses and cancels the stream", async () => {
175+
let cancelCount = 0;
176+
const release = vi.fn(async () => {});
177+
postJsonRequestMock
178+
.mockResolvedValueOnce({
179+
response: oversizedGoogleTtsJsonResponse(() => {
180+
cancelCount += 1;
181+
}),
182+
release,
183+
})
184+
.mockResolvedValueOnce({
185+
response: oversizedGoogleTtsJsonResponse(() => {
186+
cancelCount += 1;
187+
}),
188+
release,
189+
});
190+
const provider = buildGoogleSpeechProvider();
191+
192+
await expect(
193+
provider.synthesize({
194+
text: "oversized tts response",
195+
cfg: {},
196+
providerConfig: {
197+
apiKey: "google-test-key",
198+
},
199+
target: "audio-file",
200+
timeoutMs: 12_000,
201+
}),
202+
).rejects.toThrow("Google TTS response: JSON response exceeds 16777216 bytes");
203+
expect(cancelCount).toBe(2);
204+
expect(release).toHaveBeenCalledTimes(2);
205+
});
206+
152207
it("transcodes Gemini PCM to Opus for voice-note targets", async () => {
153208
installGoogleTtsRequestMock(Buffer.from([5, 0, 6, 0]));
154209
transcodeAudioBufferToOpusMock.mockResolvedValueOnce(Buffer.from("google-opus"));

extensions/google/speech-provider.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { transcodeAudioBufferToOpus } from "openclaw/plugin-sdk/media-runtime";
33
import {
44
assertOkOrThrowProviderError,
55
postJsonRequest,
6+
readProviderJsonResponse,
67
sanitizeConfiguredModelProviderRequest,
78
} from "openclaw/plugin-sdk/provider-http";
89
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard";
@@ -503,7 +504,11 @@ async function synthesizeGoogleTtsPcmOnce(params: {
503504
}
504505
}
505506
try {
506-
return extractGoogleSpeechPcm((await res.json()) as GoogleGenerateSpeechResponse);
507+
const payload = await readProviderJsonResponse<GoogleGenerateSpeechResponse>(
508+
res,
509+
"Google TTS response",
510+
);
511+
return extractGoogleSpeechPcm(payload);
507512
} catch (err) {
508513
const message = err instanceof Error ? err.message : String(err);
509514
throw new GoogleTtsRetryableError(message);

src/plugin-sdk/test-helpers/provider-http-mocks.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,50 @@ vi.mock("openclaw/plugin-sdk/provider-http", () => ({
233233
pollProviderOperationJson: providerHttpMocks.pollProviderOperationJsonMock,
234234
postJsonRequest: providerHttpMocks.postJsonRequestMock,
235235
postMultipartRequest: providerHttpMocks.postMultipartRequestMock,
236+
readProviderJsonResponse: async <T>(
237+
response: Response,
238+
label: string,
239+
opts?: { maxBytes?: number },
240+
): Promise<T> => {
241+
const maxBytes = opts?.maxBytes ?? 16 * 1024 * 1024;
242+
if (!response.body) {
243+
try {
244+
return (await response.json()) as T;
245+
} catch (cause) {
246+
throw new Error(`${label}: malformed JSON response`, { cause });
247+
}
248+
}
249+
const reader = response.body.getReader();
250+
const chunks: Uint8Array[] = [];
251+
let totalBytes = 0;
252+
try {
253+
for (;;) {
254+
const { done, value } = await reader.read();
255+
if (done) {
256+
break;
257+
}
258+
totalBytes += value.byteLength;
259+
if (totalBytes > maxBytes) {
260+
await reader.cancel();
261+
throw new Error(`${label}: JSON response exceeds ${maxBytes} bytes`);
262+
}
263+
chunks.push(value);
264+
}
265+
} finally {
266+
reader.releaseLock();
267+
}
268+
const body = new Uint8Array(totalBytes);
269+
let offset = 0;
270+
for (const chunk of chunks) {
271+
body.set(chunk, offset);
272+
offset += chunk.byteLength;
273+
}
274+
try {
275+
return JSON.parse(new TextDecoder().decode(body)) as T;
276+
} catch (cause) {
277+
throw new Error(`${label}: malformed JSON response`, { cause });
278+
}
279+
},
236280
providerOperationRetryConfig: (_stage: string) => true,
237281
resolveProviderOperationTimeoutMs: ({ defaultTimeoutMs }: { defaultTimeoutMs: number }) =>
238282
defaultTimeoutMs,

0 commit comments

Comments
 (0)