fix(google): bound TTS success JSON response reads (#96984)

This commit is contained in:
mushuiyu886
2026-06-27 07:00:36 -07:00
committed by GitHub
parent a7bfc06f45
commit 5ccfc97b31
3 changed files with 105 additions and 1 deletions
+55
View File
@@ -20,6 +20,8 @@ const {
let buildGoogleSpeechProvider: typeof import("./speech-provider.js").buildGoogleSpeechProvider;
let testing: typeof import("./speech-provider.js").testing;
const GOOGLE_TTS_JSON_CAP_BYTES = 16 * 1024 * 1024;
beforeAll(async () => {
({ buildGoogleSpeechProvider, testing } = await import("./speech-provider.js"));
});
@@ -56,6 +58,26 @@ function installGoogleTtsRequestMock(pcm = Buffer.from([1, 0, 2, 0])) {
return postJsonRequestMock;
}
function oversizedGoogleTtsJsonResponse(onCancel: () => void): Response {
const response = new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(GOOGLE_TTS_JSON_CAP_BYTES + 1));
},
cancel() {
onCancel();
},
}),
{ headers: { "content-type": "application/json" }, status: 200 },
);
Object.defineProperty(response, "json", {
value: async () => {
throw new Error("unbounded json reader was used");
},
});
return response;
}
function expectRecordFields(value: unknown, expected: Record<string, unknown>) {
if (!value || typeof value !== "object") {
throw new Error("Expected record");
@@ -149,6 +171,39 @@ describe("Google speech provider", () => {
expect(transcodeAudioBufferToOpusMock).not.toHaveBeenCalled();
});
it("bounds oversized Gemini TTS success JSON responses and cancels the stream", async () => {
let cancelCount = 0;
const release = vi.fn(async () => {});
postJsonRequestMock
.mockResolvedValueOnce({
response: oversizedGoogleTtsJsonResponse(() => {
cancelCount += 1;
}),
release,
})
.mockResolvedValueOnce({
response: oversizedGoogleTtsJsonResponse(() => {
cancelCount += 1;
}),
release,
});
const provider = buildGoogleSpeechProvider();
await expect(
provider.synthesize({
text: "oversized tts response",
cfg: {},
providerConfig: {
apiKey: "google-test-key",
},
target: "audio-file",
timeoutMs: 12_000,
}),
).rejects.toThrow("Google TTS response: JSON response exceeds 16777216 bytes");
expect(cancelCount).toBe(2);
expect(release).toHaveBeenCalledTimes(2);
});
it("transcodes Gemini PCM to Opus for voice-note targets", async () => {
installGoogleTtsRequestMock(Buffer.from([5, 0, 6, 0]));
transcodeAudioBufferToOpusMock.mockResolvedValueOnce(Buffer.from("google-opus"));
+6 -1
View File
@@ -3,6 +3,7 @@ import { transcodeAudioBufferToOpus } from "openclaw/plugin-sdk/media-runtime";
import {
assertOkOrThrowProviderError,
postJsonRequest,
readProviderJsonResponse,
sanitizeConfiguredModelProviderRequest,
} from "openclaw/plugin-sdk/provider-http";
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard";
@@ -503,7 +504,11 @@ async function synthesizeGoogleTtsPcmOnce(params: {
}
}
try {
return extractGoogleSpeechPcm((await res.json()) as GoogleGenerateSpeechResponse);
const payload = await readProviderJsonResponse<GoogleGenerateSpeechResponse>(
res,
"Google TTS response",
);
return extractGoogleSpeechPcm(payload);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new GoogleTtsRetryableError(message);
@@ -233,6 +233,50 @@ vi.mock("openclaw/plugin-sdk/provider-http", () => ({
pollProviderOperationJson: providerHttpMocks.pollProviderOperationJsonMock,
postJsonRequest: providerHttpMocks.postJsonRequestMock,
postMultipartRequest: providerHttpMocks.postMultipartRequestMock,
readProviderJsonResponse: async <T>(
response: Response,
label: string,
opts?: { maxBytes?: number },
): Promise<T> => {
const maxBytes = opts?.maxBytes ?? 16 * 1024 * 1024;
if (!response.body) {
try {
return (await response.json()) as T;
} catch (cause) {
throw new Error(`${label}: malformed JSON response`, { cause });
}
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let totalBytes = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}
totalBytes += value.byteLength;
if (totalBytes > maxBytes) {
await reader.cancel();
throw new Error(`${label}: JSON response exceeds ${maxBytes} bytes`);
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
const body = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.byteLength;
}
try {
return JSON.parse(new TextDecoder().decode(body)) as T;
} catch (cause) {
throw new Error(`${label}: malformed JSON response`, { cause });
}
},
providerOperationRetryConfig: (_stage: string) => true,
resolveProviderOperationTimeoutMs: ({ defaultTimeoutMs }: { defaultTimeoutMs: number }) =>
defaultTimeoutMs,