Files
OmniRoute/open-sse/executors/awsPollyTts.ts
Diego Rodrigues de Sa e Souza 6695cbbf7a feat: add EdgeTTS audio-tts provider (#6668) (#7605)
* feat(sse): add EdgeTTS audio-tts provider (#6668)

Registers Microsoft Edge "Read Aloud" as a new no-API-key AUDIO_SPEECH_PROVIDERS
entry — the first WebSocket-transport TTS provider in the registry. Reverse-
engineered/unofficial endpoint, same class of integration already accepted for
other "-web" style providers (chatgpt-web.ts, copilot-web.ts).

- open-sse/executors/edgeTts.ts: pure Sec-MS-GEC token construction (SHA-256
  over a public trusted-client-token + rounded Windows file-time ticks, ported
  from rany2/edge-tts drm.py), WS message framing (speech.config/ssml),
  binary-chunk demuxing, SSML building/escaping, and the WS synth call itself
  (injectable WebSocket ctor for tests, lazy `import("ws")` in production so
  it never enters esbuild's top-level CJS bundle graph). Per-client-IP
  sliding-window throttle (SlidingWindowLimiter) since there's no per-user key
  — one abusive deployment could otherwise get the shared trusted token
  rate-limited for everyone.
- open-sse/utils/publicCreds.ts: embeds the trusted-client-token via
  resolvePublicCred() (Hard Rule #11) — it's a constant hardcoded in every
  Edge build and every open-source edge-tts port, not a per-user secret.
- Extracted open-sse/utils/audioResponse.ts (shared response helpers) and
  open-sse/executors/awsPollyTts.ts (AWS Polly handler) out of
  open-sse/handlers/audioSpeech.ts to stay under its frozen file-size ratchet
  baseline while making room for the new branch — no behavior change to
  either extracted piece.
- src/app/api/v1/audio/speech/route.ts: thread the caller's IP through to the
  handler for the new throttle.

Tests: tests/unit/edgetts-provider.test.ts (23 cases) — Sec-MS-GEC determinism
and cross-check against a hand-derived reference vector, message framing,
binary demux, SSML escaping/injection-safety, registry lookup, publicCreds
shape, and the error path via an injected fake WebSocket (upstream failure ->
sanitized 502, no stack/path leak; Hard Rule #12), plus the per-IP rate limit.
No live upstream is required or used — the reverse-engineered protocol can't
be validated against real credentials, but every pure/testable seam is
covered per the TDD path in the bug/feature validation gate.

* test(mutation): register edgetts-provider.test.ts in stryker tap.testFiles (#6668)

The new provider's unit test covers a mutated module, so the strict
mutation-test-coverage gate requires it in stryker.conf.json's
tap.testFiles. Single-line addition (kept the file's existing formatting).
2026-07-17 11:32:25 -03:00

163 lines
5.2 KiB
TypeScript

/**
* AWS Polly TTS handler.
*
* Extracted out of `open-sse/handlers/audioSpeech.ts` (frozen at its
* file-size ratchet baseline — config/quality/file-size-baseline.json) to
* make room for the new EdgeTTS WebSocket branch (#6668). Pure provider
* adapter, no behavior change vs. the original inline implementation.
*
* POST /v1/speech signed with AWS SigV4. The configured apiKey stores AWS
* Secret Access Key; providerSpecificData.accessKeyId stores AWS Access Key
* ID, with optional region/baseUrl/defaultVoice/sessionToken.
*/
import { stripTrailingSlashes } from "../utils/urlSanitize.ts";
import { signAwsRequest } from "../utils/awsSigV4.ts";
import { errorResponse } from "../utils/error.ts";
import { audioStreamResponse, upstreamErrorResponse } from "../utils/audioResponse.ts";
function getStringValue(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function getAwsPollyProviderData(credentials) {
return credentials?.providerSpecificData &&
typeof credentials.providerSpecificData === "object" &&
!Array.isArray(credentials.providerSpecificData)
? credentials.providerSpecificData
: {};
}
function resolveAwsPollyRegion(providerSpecificData) {
return (
getStringValue(providerSpecificData.region) ||
getStringValue(providerSpecificData.awsRegion) ||
process.env.AWS_REGION ||
process.env.AWS_DEFAULT_REGION ||
"us-east-1"
);
}
function resolveAwsPollyBaseUrl(providerSpecificData, region) {
const configuredBaseUrl = getStringValue(providerSpecificData.baseUrl);
const baseUrl = configuredBaseUrl || `https://polly.${region}.amazonaws.com`;
return stripTrailingSlashes(baseUrl.replace(/\/v1\/speech\/?$/i, ""));
}
function normalizeAwsPollyEngine(modelId) {
const engine = getStringValue(modelId) || "standard";
return ["standard", "neural", "long-form", "generative"].includes(engine) ? engine : "standard";
}
function normalizeAwsPollyOutputFormat(responseFormat) {
const format = getStringValue(responseFormat)?.toLowerCase();
switch (format) {
case "pcm":
case "wav":
return "pcm";
case "opus":
case "ogg_opus":
return "ogg_opus";
case "ogg":
case "ogg_vorbis":
return "ogg_vorbis";
case "json":
return "json";
case "mp3":
default:
return "mp3";
}
}
function normalizeAwsPollyTextType(body) {
const explicitTextType = getStringValue(body.text_type || body.textType)?.toLowerCase();
if (explicitTextType === "ssml") return "ssml";
if (explicitTextType === "text") return "text";
const input = getStringValue(body.input) || "";
return input.trim().startsWith("<speak") ? "ssml" : "text";
}
function getAwsPollySampleRate(responseFormat, sampleRate) {
const explicit = getStringValue(sampleRate || null);
if (explicit) return explicit;
const outputFormat = normalizeAwsPollyOutputFormat(responseFormat);
if (outputFormat === "ogg_opus") return "48000";
if (outputFormat === "pcm") return "16000";
return undefined;
}
export async function handleAwsPollySpeech(
providerConfig,
body,
modelId,
token,
credentials
): Promise<Response> {
const providerSpecificData = getAwsPollyProviderData(credentials);
const accessKeyId =
getStringValue(providerSpecificData.accessKeyId) ||
getStringValue(providerSpecificData.awsAccessKeyId);
const secretAccessKey = getStringValue(token);
if (!accessKeyId) {
return errorResponse(400, "AWS Polly requires providerSpecificData.accessKeyId");
}
if (!secretAccessKey) {
return errorResponse(401, "No AWS Secret Access Key for AWS Polly");
}
const region = resolveAwsPollyRegion(providerSpecificData);
const baseUrl = resolveAwsPollyBaseUrl(providerSpecificData, region);
const url = `${baseUrl}/v1/speech`;
const outputFormat = normalizeAwsPollyOutputFormat(body.response_format);
const sampleRate = getAwsPollySampleRate(
body.response_format,
body.sample_rate || body.sampleRate
);
const requestBody = {
Engine: normalizeAwsPollyEngine(modelId),
OutputFormat: outputFormat,
Text: body.input,
TextType: normalizeAwsPollyTextType(body),
VoiceId:
getStringValue(body.voice) || getStringValue(providerSpecificData.defaultVoice) || "Joanna",
...(getStringValue(body.language_code || body.languageCode)
? { LanguageCode: getStringValue(body.language_code || body.languageCode) }
: {}),
...(sampleRate ? { SampleRate: sampleRate } : {}),
};
const serializedBody = JSON.stringify(requestBody);
const signedHeaders = signAwsRequest({
method: "POST",
url,
region,
service: "polly",
headers: {
"content-type": "application/json",
},
body: serializedBody,
credentials: {
accessKeyId,
secretAccessKey,
sessionToken:
getStringValue(providerSpecificData.sessionToken) ||
getStringValue(providerSpecificData.awsSessionToken),
},
});
const res = await fetch(url, {
method: "POST",
headers: signedHeaders,
body: serializedBody,
});
if (!res.ok) {
return upstreamErrorResponse(res, await res.text());
}
return audioStreamResponse(res, outputFormat === "pcm" ? "audio/pcm" : "audio/mpeg");
}