mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
65d64818bb
* fix(tts): reject non-audio speech synthesis responses A successful HTTP 200 carrying a JSON error, an HTML sign-in page, or a zero-byte body was returned as an audio buffer and written out as a playable attachment. Route the xAI, Gradium, and Azure Speech synthesizers through the shared binary-response contract that the OpenAI-compatible speech provider already uses, so the media kind is validated before the body is read and the unread body is canceled when it is rejected. Each provider keeps its own overflow wording, so operator-facing messages are unchanged apart from the new malformed-response rejection. * test(azure-speech): prove the audio guard closes a real upstream socket Adds a loopback node:http server that answers 200 with a JSON content type and then never ends the body, driven through the real azureSpeechTTS path with the real global fetch and no stubbed transport. Without the guard the synthesis hangs to its timeout with the socket still open; with it the response is rejected immediately and the server observes the close. * fix(openai): reject malformed binary speech responses --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
80 lines
2.4 KiB
TypeScript
80 lines
2.4 KiB
TypeScript
// Gradium plugin module implements tts behavior.
|
|
import {
|
|
assertOkOrThrowProviderError,
|
|
assertProviderBinaryResponseContent,
|
|
} from "openclaw/plugin-sdk/provider-http";
|
|
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
|
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
|
import { GRADIUM_API_HOSTNAME, normalizeGradiumBaseUrl } from "./shared.js";
|
|
|
|
const DEFAULT_TTS_MAX_BYTES = 16 * 1024 * 1024;
|
|
|
|
export async function gradiumTTS(params: {
|
|
text: string;
|
|
apiKey: string;
|
|
baseUrl: string;
|
|
voiceId: string;
|
|
outputFormat: "wav" | "opus" | "ulaw_8000" | "pcm" | "pcm_24000" | "alaw_8000";
|
|
timeoutMs: number;
|
|
maxBytes?: number;
|
|
}): Promise<Buffer> {
|
|
const {
|
|
text,
|
|
apiKey,
|
|
baseUrl,
|
|
voiceId,
|
|
outputFormat,
|
|
timeoutMs,
|
|
maxBytes = DEFAULT_TTS_MAX_BYTES,
|
|
} = params;
|
|
const normalizedBaseUrl = normalizeGradiumBaseUrl(baseUrl);
|
|
const url = `${normalizedBaseUrl}/api/post/speech/tts`;
|
|
|
|
const { response, release } = await fetchWithSsrFGuard({
|
|
url,
|
|
init: {
|
|
method: "POST",
|
|
headers: {
|
|
"x-api-key": apiKey,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
text,
|
|
voice_id: voiceId,
|
|
only_audio: true,
|
|
output_format: outputFormat,
|
|
json_config: JSON.stringify({ padding_bonus: 0 }),
|
|
}),
|
|
},
|
|
timeoutMs,
|
|
requireHttps: true,
|
|
// Keep the transport boundary independent from config normalization so a
|
|
// future validator relaxation cannot silently widen credential egress.
|
|
policy: { hostnameAllowlist: [GRADIUM_API_HOSTNAME] },
|
|
auditContext: "gradium.tts",
|
|
});
|
|
|
|
try {
|
|
await assertOkOrThrowProviderError(response, "Gradium API error");
|
|
|
|
try {
|
|
assertProviderBinaryResponseContent(response, "Gradium API error", "audio");
|
|
} catch (error) {
|
|
// A debug-capture clone can keep the tee open, so waiting for cancel would hang
|
|
// before the rejected response and its dispatcher can be released.
|
|
void response.body?.cancel().catch(() => undefined);
|
|
throw error;
|
|
}
|
|
const audio = await readResponseWithLimit(response, maxBytes, {
|
|
onOverflow: ({ maxBytes: maxBytesLocal }) =>
|
|
new Error(`Gradium TTS audio response exceeds ${maxBytesLocal} bytes`),
|
|
});
|
|
if (audio.byteLength === 0) {
|
|
throw new Error("Gradium API error: malformed audio response");
|
|
}
|
|
return audio;
|
|
} finally {
|
|
await release();
|
|
}
|
|
}
|