mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(google): bound TTS success JSON response reads (#96984)
This commit is contained in:
@@ -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"));
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user