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).
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-17 11:32:25 -03:00
committed by GitHub
parent df1ed57876
commit 6695cbbf7a
11 changed files with 923 additions and 196 deletions

View File

@@ -0,0 +1 @@
- **feat(sse):** add EdgeTTS (Microsoft Edge "Read Aloud") as a free, no-API-key `audio-tts` provider — the first WebSocket-transport speech provider, with per-client-IP rate limiting. (#6668)

View File

@@ -945,6 +945,7 @@ curl -X POST http://localhost:20128/v1/audio/transcriptions \
- `kie/`
- `aws-polly/`
- `xiaomi-mimo/`
- `edgetts/` (Microsoft Edge "Read Aloud" — free, no API key; unofficial/reverse-engineered endpoint)
- `coqui/`, `tortoise/`
- `qwen/`

View File

@@ -419,6 +419,31 @@ export const AUDIO_SPEECH_PROVIDERS: Record<string, AudioProvider> = {
],
},
edgetts: {
id: "edgetts",
// Microsoft Edge "Read Aloud" — reverse-engineered, no API key required.
// WebSocket transport (unlike every other entry here) — handled by
// open-sse/executors/edgeTts.ts, dispatched via the "edgetts" format.
baseUrl: "wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1",
authType: "none",
authHeader: "none",
format: "edgetts",
supportedFormats: ["mp3"],
models: [
{ id: "en-US-AriaNeural", name: "Aria (EN-US, Female)" },
{ id: "en-US-GuyNeural", name: "Guy (EN-US, Male)" },
{ id: "en-GB-SoniaNeural", name: "Sonia (EN-GB, Female)" },
{ id: "en-GB-RyanNeural", name: "Ryan (EN-GB, Male)" },
{ id: "es-ES-ElviraNeural", name: "Elvira (ES-ES, Female)" },
{ id: "pt-BR-FranciscaNeural", name: "Francisca (PT-BR, Female)" },
{ id: "pt-BR-AntonioNeural", name: "Antonio (PT-BR, Male)" },
{ id: "fr-FR-DeniseNeural", name: "Denise (FR-FR, Female)" },
{ id: "de-DE-KatjaNeural", name: "Katja (DE-DE, Female)" },
{ id: "ja-JP-NanamiNeural", name: "Nanami (JA-JP, Female)" },
{ id: "zh-CN-XiaoxiaoNeural", name: "Xiaoxiao (ZH-CN, Female)" },
],
},
"xiaomi-mimo": {
id: "xiaomi-mimo",
baseUrl: "https://api.xiaomimimo.com/v1/chat/completions",

View File

@@ -0,0 +1,162 @@
/**
* 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");
}

View File

@@ -0,0 +1,354 @@
/**
* EdgeTTS — Microsoft Edge "Read Aloud" text-to-speech (#6668).
*
* Reverse-engineered, unofficial, undocumented endpoint (not a published
* Microsoft public API) — the same class of integration this codebase
* already accepts for other "-web" style providers (chatgpt-web.ts,
* copilot-web.ts). No user account/API key is required; Microsoft gates
* abuse with a `Sec-MS-GEC` header computed from a public "trusted client
* token" (see `open-sse/utils/publicCreds.ts::edgetts_token` — Hard Rule
* #11, this is a constant hardcoded in every Edge browser build and every
* open-source edge-tts reimplementation, not a per-user secret).
*
* Protocol (verified against rany2/edge-tts + msedge-tts + edge-tts-universal):
* 1. WS connect to
* wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1
* with `TrustedClientToken`, `Sec-MS-GEC`, `Sec-MS-GEC-Version` query params.
* 2. Send a `speech.config` text frame (output format, metadata options).
* 3. Send an `ssml` text frame carrying the SSML payload to synthesize.
* 4. Receive interleaved text frames (turn.start / audio.metadata / turn.end)
* and binary frames — each binary frame is a 2-byte big-endian header
* length, followed by ASCII headers, followed by raw audio bytes.
* 5. `turn.end` (or WS close) marks the end of the stream; concatenated
* audio chunks are the final MP3.
*
* All parsing above (Sec-MS-GEC HMAC input, message framing, binary chunk
* demux) is implemented as pure functions so it can be unit-tested without a
* live upstream connection — only `synthesizeEdgeTts()` itself touches the
* network, and it accepts an injectable WebSocket constructor for tests.
*/
import { createHash, randomBytes } from "node:crypto";
import { resolvePublicCred } from "../utils/publicCreds.ts";
import { errorResponse } from "../utils/error.ts";
import { SlidingWindowLimiter } from "../services/slidingWindowLimiter.ts";
const EDGE_TTS_WS_URL =
"wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1";
const EDGE_TTS_GEC_VERSION = "1-138.0.0.0";
const WIN_EPOCH_OFFSET_SECONDS = 11644473600;
const SEC_MS_GEC_ROUND_SECONDS = 300; // 5 minutes
const DEFAULT_VOICE = "en-US-AriaNeural";
const DEFAULT_OUTPUT_FORMAT = "audio-24khz-48kbitrate-mono-mp3";
const CONNECT_TIMEOUT_MS = 10_000;
const SYNTH_TIMEOUT_MS = 30_000;
// Per-client-IP throttle — EdgeTTS has no per-user key, so every OmniRoute
// deployment shares the same trusted-token identity upstream. A single
// abusive caller could get the shared token rate-limited/blocked for
// everyone, so we cap requests per source IP before we ever open a socket.
const EDGE_TTS_RATE_WINDOW = { requests: 20, windowMs: 60_000 };
const edgeTtsLimiter = new SlidingWindowLimiter();
export interface EdgeTtsSynthInput {
text: string;
voice?: string;
rate?: string;
pitch?: string;
volume?: string;
}
export interface EdgeTtsSynthResult {
audio: Buffer;
contentType: string;
}
/**
* A minimal shape of the subset of the `ws`/DOM WebSocket API this module
* needs — lets tests inject a fake implementation without touching the real
* network or the `ws` package.
*/
export interface MinimalWebSocket {
on(event: "open" | "message" | "close" | "error", listener: (...args: unknown[]) => void): void;
send(data: string): void;
close(): void;
}
export type WebSocketCtor = new (url: string, opts?: unknown) => MinimalWebSocket;
// ─── Pure helpers (unit-testable, no I/O) ──────────────────────────────────
/**
* Compute the `Sec-MS-GEC` anti-abuse token Microsoft's Read Aloud endpoint
* requires. `nowMs` is injectable so the function is deterministic in tests.
* Algorithm ported from rany2/edge-tts `drm.py::generate_sec_ms_gec()`.
*/
export function computeSecMsGec(nowMs: number = Date.now()): string {
let ticks = nowMs / 1000 + WIN_EPOCH_OFFSET_SECONDS;
ticks -= ticks % SEC_MS_GEC_ROUND_SECONDS;
ticks *= 1e7; // seconds -> 100-nanosecond Windows file-time ticks
const strToHash = `${Math.floor(ticks)}${resolvePublicCred("edgetts_token")}`;
return createHash("sha256").update(strToHash, "ascii").digest("hex").toUpperCase();
}
/** Random 32-hex-char connection id (no dashes), as the protocol expects. */
export function buildConnectionId(): string {
return randomBytes(16).toString("hex");
}
function toIsoTimestamp(): string {
// Edge's protocol wants a JS-Date-toString-like timestamp; ISO is accepted
// by every reference implementation and is trivially deterministic/testable.
return new Date().toUTCString();
}
/** Build the `speech.config` WS text frame sent right after connecting. */
export function buildSpeechConfigMessage(timestamp: string = toIsoTimestamp()): string {
const config = {
context: {
synthesis: {
audio: {
metadataoptions: {
sentenceBoundaryEnabled: "false",
wordBoundaryEnabled: "false",
},
outputFormat: DEFAULT_OUTPUT_FORMAT,
},
},
},
};
return (
`X-Timestamp:${timestamp}\r\n` +
`Content-Type:application/json; charset=utf-8\r\n` +
`Path:speech.config\r\n\r\n` +
`${JSON.stringify(config)}`
);
}
/** Escape user text for safe embedding inside an SSML `<voice>` element. */
export function escapeSsmlText(text: string): string {
return String(text ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
/** Normalize a caller-supplied voice name, falling back to the default voice. */
export function normalizeEdgeVoice(voice: unknown): string {
const value = typeof voice === "string" ? voice.trim() : "";
// Edge voice names are e.g. "en-US-AriaNeural" — locale-Name-Neural.
return /^[A-Za-z]{2,3}-[A-Za-z]{2,3}-[A-Za-z0-9]+Neural$/.test(value) ? value : DEFAULT_VOICE;
}
function clampProsodyValue(value: unknown, fallback: string): string {
const str = typeof value === "string" ? value.trim() : "";
// Accept "+10%", "-20%", "default", or a bare number — reject anything else
// to keep this untrusted-input path from injecting SSML markup.
return /^(default|[+-]?\d{1,3}%|[+-]?\d{1,3}(\.\d+)?)$/.test(str) ? str : fallback;
}
/** Build the full SSML payload for one synthesis request. */
export function buildSsml(input: EdgeTtsSynthInput): string {
const voice = normalizeEdgeVoice(input.voice);
const rate = clampProsodyValue(input.rate, "default");
const pitch = clampProsodyValue(input.pitch, "default");
const volume = clampProsodyValue(input.volume, "default");
const text = escapeSsmlText(input.text);
return (
`<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='en-US'>` +
`<voice name='${voice}'>` +
`<prosody rate='${rate}' pitch='${pitch}' volume='${volume}'>${text}</prosody>` +
`</voice></speak>`
);
}
/** Build the `ssml` WS text frame carrying the synthesis payload. */
export function buildSsmlMessage(
requestId: string,
ssml: string,
timestamp: string = toIsoTimestamp()
): string {
return (
`X-RequestId:${requestId}\r\n` +
`Content-Type:application/ssml+xml\r\n` +
`X-Timestamp:${timestamp}\r\n` +
`Path:ssml\r\n\r\n` +
`${ssml}`
);
}
/** True when a received text frame marks the end of the synthesis turn. */
export function isTurnEndMessage(message: string): boolean {
return typeof message === "string" && message.includes("Path:turn.end");
}
/**
* Demux one binary WS frame into its header block and raw audio payload.
* Frame shape: 2-byte big-endian header length, then that many bytes of
* ASCII headers, then the remaining bytes are audio data. Returns `null`
* for a frame too short to contain a valid header-length prefix.
*/
export function demuxAudioChunk(
frame: Buffer
): { headers: string; audio: Buffer } | null {
if (!Buffer.isBuffer(frame) || frame.length < 2) return null;
const headerLength = frame.readUInt16BE(0);
if (2 + headerLength > frame.length) return null;
const headers = frame.subarray(2, 2 + headerLength).toString("ascii");
const audio = frame.subarray(2 + headerLength);
return { headers, audio };
}
/** Build the WS connection URL, including the freshly-computed Sec-MS-GEC token. */
export function buildEdgeTtsWsUrl(nowMs: number = Date.now()): string {
const params = new URLSearchParams({
TrustedClientToken: resolvePublicCred("edgetts_token"),
"Sec-MS-GEC": computeSecMsGec(nowMs),
"Sec-MS-GEC-Version": EDGE_TTS_GEC_VERSION,
ConnectionId: buildConnectionId(),
});
return `${EDGE_TTS_WS_URL}?${params.toString()}`;
}
// ─── Network I/O ────────────────────────────────────────────────────────────
/**
* Open a WS connection to Edge's Read Aloud service and synthesize `input`.
* `WebSocketCtor` is injectable for tests; production callers omit it and
* this lazily imports the `ws` package (mirrors the pattern used in
* copilot-web.ts / chipotle.ts — keeps `ws` out of the esbuild CJS bundle's
* top-level graph).
*/
export async function synthesizeEdgeTts(
input: EdgeTtsSynthInput,
WebSocketCtor?: WebSocketCtor
): Promise<EdgeTtsSynthResult> {
const Ctor = WebSocketCtor ?? ((await import("ws")).default as unknown as WebSocketCtor);
const url = buildEdgeTtsWsUrl();
const ssml = buildSsml(input);
const requestId = buildConnectionId();
return new Promise<EdgeTtsSynthResult>((resolve, reject) => {
const chunks: Buffer[] = [];
let settled = false;
let contentType = "audio/mpeg";
const finish = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
fn();
};
const timer = setTimeout(() => {
finish(() => {
try {
ws.close();
} catch {
// best-effort close on timeout
}
reject(new Error("EdgeTTS synthesis timed out"));
});
}, SYNTH_TIMEOUT_MS);
let ws: MinimalWebSocket;
try {
ws = new Ctor(url, { handshakeTimeout: CONNECT_TIMEOUT_MS });
} catch (err) {
clearTimeout(timer);
reject(err instanceof Error ? err : new Error(String(err)));
return;
}
ws.on("open", () => {
ws.send(buildSpeechConfigMessage());
ws.send(buildSsmlMessage(requestId, ssml));
});
ws.on("message", (data: unknown, isBinary?: unknown) => {
const binary = isBinary === true || Buffer.isBuffer(data);
if (binary) {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data as ArrayBuffer);
const demuxed = demuxAudioChunk(buf);
if (demuxed) {
const typeMatch = /Content-Type:\s*([^\r\n]+)/i.exec(demuxed.headers);
if (typeMatch) contentType = typeMatch[1].trim();
if (demuxed.audio.length > 0) chunks.push(demuxed.audio);
}
return;
}
const text = String(data);
if (isTurnEndMessage(text)) {
finish(() => {
try {
ws.close();
} catch {
// best-effort close
}
resolve({ audio: Buffer.concat(chunks), contentType });
});
}
});
ws.on("error", (err: unknown) => {
finish(() => reject(err instanceof Error ? err : new Error(String(err))));
});
ws.on("close", () => {
finish(() => {
if (chunks.length > 0) {
resolve({ audio: Buffer.concat(chunks), contentType });
} else {
reject(new Error("EdgeTTS connection closed before receiving audio"));
}
});
});
});
}
// ─── Handler entrypoint (called from audioSpeech.ts) ───────────────────────
/**
* Handle an EdgeTTS `/v1/audio/speech` request. `clientIp` is optional — when
* provided, this enforces the per-IP sliding-window throttle described above.
*/
export async function handleEdgeTtsSpeech(
body: { input?: unknown; voice?: unknown },
clientIp?: string | null,
WebSocketCtor?: WebSocketCtor
): Promise<Response> {
if (clientIp) {
const { allowed, retryAfterMs } = edgeTtsLimiter.tryAcquire(clientIp, EDGE_TTS_RATE_WINDOW);
if (!allowed) {
return errorResponse(
429,
`EdgeTTS rate limit exceeded, retry after ${Math.ceil(retryAfterMs / 1000)}s`
);
}
}
const text = typeof body?.input === "string" ? body.input : "";
if (!text.trim()) {
return errorResponse(400, "input is required");
}
try {
const { audio, contentType } = await synthesizeEdgeTts(
{
text,
voice: typeof body.voice === "string" ? body.voice : undefined,
},
WebSocketCtor
);
return new Response(audio, {
status: 200,
headers: { "Content-Type": contentType },
});
} catch (err) {
return errorResponse(
502,
`EdgeTTS request failed: ${err instanceof Error ? err.message : String(err)}`
);
}
}

View File

@@ -21,7 +21,10 @@ import { getSpeechProvider, parseSpeechModel } from "../config/audioRegistry.ts"
import { buildAuthHeaders } from "../config/registryUtils.ts";
import { kieExecutor } from "../executors/kie.ts";
import { vertexGenerateSpeech } from "../executors/vertexMedia.ts";
import { handleAwsPollySpeech } from "../executors/awsPollyTts.ts";
import { handleEdgeTtsSpeech } from "../executors/edgeTts.ts";
import { errorResponse } from "../utils/error.ts";
import { audioStreamResponse, upstreamErrorResponse } from "../utils/audioResponse.ts";
import {
getKieCallbackUrl,
getKieErrorMessage,
@@ -29,59 +32,6 @@ import {
isJsonObject,
parseKieResultJson,
} from "../utils/kieTask.ts";
import { signAwsRequest } from "../utils/awsSigV4.ts";
/**
* Return a CORS error response from an upstream fetch failure
*/
function extractUpstreamErrorMessage(parsed) {
const detail = parsed?.detail;
const candidates = [
parsed?.err_msg,
parsed?.error?.message,
typeof parsed?.error === "string" ? parsed.error : null,
parsed?.message,
typeof detail === "string" ? detail : detail?.message,
];
const raw = candidates.find(Boolean);
return raw ? String(raw) : null;
}
function upstreamErrorResponse(res, errText) {
// Always return JSON so the client can detect 401/credential errors reliably
let errorMessage: string;
try {
const parsed = JSON.parse(errText);
errorMessage =
extractUpstreamErrorMessage(parsed) || errText || `Upstream error (${res.status})`;
} catch {
errorMessage = errText || `Upstream error (${res.status})`;
}
return Response.json(
{ error: { message: errorMessage, code: res.status } },
{
status: res.status,
headers: { ...CORS_HEADERS },
}
);
}
/**
* Return a CORS audio stream response
*/
function audioStreamResponse(res, defaultContentType = "audio/mpeg") {
const contentType = res.headers.get("content-type") || defaultContentType;
return new Response(res.body, {
status: 200,
headers: {
...CORS_HEADERS,
"Content-Type": contentType,
"Transfer-Encoding": "chunked",
},
});
}
function normalizeKieElevenLabsVoice(voice: unknown): string {
const value = typeof voice === "string" ? voice.trim() : "";
@@ -178,30 +128,6 @@ function getStringValue(value): 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 getProviderSpecificData(credentials) {
return credentials?.providerSpecificData &&
typeof credentials.providerSpecificData === "object" &&
@@ -249,50 +175,6 @@ function getXiaomiMimoAudioData(data) {
);
}
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;
}
/**
* Handle Hyperbolic TTS (returns base64 audio in JSON)
*/
@@ -648,80 +530,6 @@ async function pollKieAudioResult(baseUrl, modelId, taskId, token) {
return errorResponse(504, "Kie audio generation timed out or failed");
}
/**
* Handle AWS Polly TTS
* 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.
*/
async function handleAwsPollySpeech(providerConfig, body, modelId, token, credentials) {
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");
}
/**
* Xiaomi MiMo TTS uses chat/completions with an audio config instead of OpenAI's /audio/speech
* request body.
@@ -931,6 +739,7 @@ export async function handleAudioSpeech({
credentials,
resolvedProvider = null,
resolvedModel = null,
clientIp = null,
}) {
if (!body.model) {
return errorResponse(400, "model is required");
@@ -952,7 +761,7 @@ export async function handleAudioSpeech({
if (!providerConfig) {
return errorResponse(
400,
`No speech provider found for model "${body.model}". Use format provider/model. Available: openai, hyperbolic, deepgram, nvidia, elevenlabs, huggingface, inworld, cartesia, playht, kie, aws-polly, xiaomi-mimo, coqui, tortoise, qwen`
`No speech provider found for model "${body.model}". Use format provider/model. Available: openai, hyperbolic, deepgram, nvidia, elevenlabs, huggingface, inworld, cartesia, playht, kie, aws-polly, xiaomi-mimo, edgetts, coqui, tortoise, qwen`
);
}
@@ -1017,6 +826,10 @@ export async function handleAudioSpeech({
return handleAwsPollySpeech(providerConfig, body, modelId, token, credentials);
}
if (providerConfig.format === "edgetts") {
return handleEdgeTtsSpeech(body, clientIp);
}
if (providerConfig.format === "xiaomi-mimo-tts") {
return handleXiaomiMimoSpeech(providerConfig, body, modelId, token, credentials);
}

View File

@@ -0,0 +1,65 @@
/**
* Shared audio/speech HTTP response helpers.
*
* Extracted from `open-sse/handlers/audioSpeech.ts` so that both the handler
* and any provider-specific adapter modules extracted alongside it (e.g.
* `open-sse/executors/awsPollyTts.ts`) can share the same response-shaping
* logic without importing from the (frozen, file-size-ratcheted) handler
* itself — which would create a circular import.
*/
import { CORS_HEADERS } from "./cors.ts";
/**
* Pull a human-readable error message out of a parsed upstream JSON error body.
*/
function extractUpstreamErrorMessage(parsed) {
const detail = parsed?.detail;
const candidates = [
parsed?.err_msg,
parsed?.error?.message,
typeof parsed?.error === "string" ? parsed.error : null,
parsed?.message,
typeof detail === "string" ? detail : detail?.message,
];
const raw = candidates.find(Boolean);
return raw ? String(raw) : null;
}
/**
* Return a CORS error response from an upstream fetch failure.
*/
export function upstreamErrorResponse(res: Response, errText: string): Response {
// Always return JSON so the client can detect 401/credential errors reliably
let errorMessage: string;
try {
const parsed = JSON.parse(errText);
errorMessage =
extractUpstreamErrorMessage(parsed) || errText || `Upstream error (${res.status})`;
} catch {
errorMessage = errText || `Upstream error (${res.status})`;
}
return Response.json(
{ error: { message: errorMessage, code: res.status } },
{
status: res.status,
headers: { ...CORS_HEADERS },
}
);
}
/**
* Return a CORS audio stream response.
*/
export function audioStreamResponse(res: Response, defaultContentType = "audio/mpeg"): Response {
const contentType = res.headers.get("content-type") || defaultContentType;
return new Response(res.body, {
status: 200,
headers: {
...CORS_HEADERS,
"Content-Type": contentType,
"Transfer-Encoding": "chunked",
},
});
}

View File

@@ -184,6 +184,15 @@ const EMBEDDED_DEFAULTS = {
],
// Trae Cloud IDE — public oauth client id
trae_id: [10, 3, 95, 6, 10, 22, 66, 3, 11, 90, 72, 31, 91, 2],
// Microsoft Edge Read Aloud (EdgeTTS) — public "trusted client token" used to
// derive the Sec-MS-GEC anti-abuse header. Hardcoded in every known Edge
// browser build and every open-source edge-tts reimplementation (e.g.
// rany2/edge-tts constants.py) — not a per-user secret, just an
// abuse-mitigation constant Microsoft ships in public client binaries.
edgetts_token: [
89, 44, 91, 40, 51, 94, 49, 64, 32, 108, 54, 51, 86, 41, 80, 37, 111, 69, 6, 42, 95, 93, 45, 68,
87, 65, 77, 84, 105, 70, 51, 86,
],
} as const;
export type EmbeddedDefaultKey = keyof typeof EMBEDDED_DEFAULTS;

View File

@@ -23,6 +23,7 @@ import {
import { attachOmniRouteMetaToResponse } from "@/domain/omnirouteResponseMeta";
import { calculateModalCost } from "@/lib/usage/costCalculator";
import { generateRequestId } from "@/shared/utils/requestId";
import { getClientIpFromRequest } from "@/lib/ipUtils";
/**
* Handle CORS preflight
@@ -112,6 +113,7 @@ async function postHandler(request, context) {
credentials,
resolvedProvider: providerConfig,
resolvedModel,
clientIp: getClientIpFromRequest(request),
});
if (response?.ok) {
await clearRecoveredProviderState(credentials);

View File

@@ -167,6 +167,7 @@
"tests/unit/custom-model-target-format.test.ts",
"tests/unit/db-reset-module-state.test.ts",
"tests/unit/domain-persistence.test.ts",
"tests/unit/edgetts-provider.test.ts",
"tests/unit/embeddings-auth.test.ts",
"tests/unit/error-classification.test.ts",
"tests/unit/error-message-sanitization.test.ts",

View File

@@ -0,0 +1,294 @@
// EdgeTTS (Microsoft Edge "Read Aloud") audio-tts provider (#6668).
//
// EdgeTTS is a reverse-engineered WebSocket endpoint with no API key, so
// there is no live upstream we can validate against in CI (Hard Rule #18
// TDD path). This suite covers everything that is a pure function: the
// Sec-MS-GEC token/HMAC construction, WS message framing, binary-chunk
// demuxing, SSML building/escaping, registry lookup, and the error path
// (mocked WebSocket failure -> sanitized error response, no stack leak).
import test from "node:test";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import {
computeSecMsGec,
buildConnectionId,
buildSpeechConfigMessage,
buildSsmlMessage,
buildSsml,
buildEdgeTtsWsUrl,
escapeSsmlText,
normalizeEdgeVoice,
isTurnEndMessage,
demuxAudioChunk,
synthesizeEdgeTts,
handleEdgeTtsSpeech,
type MinimalWebSocket,
} from "../../open-sse/executors/edgeTts.ts";
import { getSpeechProvider, parseSpeechModel } from "../../open-sse/config/audioRegistry.ts";
import { resolvePublicCred } from "../../open-sse/utils/publicCreds.ts";
// ─── Sec-MS-GEC token (HMAC-ish SHA-256 construction) ──────────────────────
test("computeSecMsGec is deterministic for the same 5-minute window", () => {
const base = Date.UTC(2026, 6, 17, 12, 0, 0); // 2026-07-17T12:00:00Z
const a = computeSecMsGec(base);
const b = computeSecMsGec(base + 60_000); // +1 minute, same 5-minute bucket
assert.equal(a, b, "same rounded-down 5-minute window must hash identically");
assert.match(a, /^[0-9A-F]{64}$/, "output must be a 64-char uppercase hex SHA-256 digest");
});
test("computeSecMsGec changes across a 5-minute window boundary", () => {
const base = Date.UTC(2026, 6, 17, 12, 0, 0);
const before = computeSecMsGec(base - 1); // just before the 5-min bucket
const after = computeSecMsGec(base);
assert.notEqual(before, after);
});
test("computeSecMsGec matches the reference rany2/edge-tts algorithm shape", () => {
// Cross-check against a hand-computed reference vector for a fixed instant,
// using the same constants/algorithm documented in drm.py:
// ticks = floor((nowMs/1000 + 11644473600) - (... % 300)) * 1e7
// sha256(`${ticks}${TRUSTED_CLIENT_TOKEN}`).hexdigest().upper()
const nowMs = Date.UTC(2026, 0, 1, 0, 0, 0);
const winEpoch = 11644473600;
let ticks = nowMs / 1000 + winEpoch;
ticks -= ticks % 300;
ticks *= 1e7;
const token = resolvePublicCred("edgetts_token");
const expected = createHash("sha256")
.update(`${Math.floor(ticks)}${token}`, "ascii")
.digest("hex")
.toUpperCase();
assert.equal(computeSecMsGec(nowMs), expected);
});
// ─── publicCreds shape assertion (Hard Rule #11) ───────────────────────────
test("edgetts_token is embedded via resolvePublicCred, not a string literal", () => {
const token = resolvePublicCred("edgetts_token");
assert.equal(typeof token, "string");
assert.ok(token.length > 0, "embedded default must decode to a non-empty token");
// The well-known public trusted-client-token format used by every Edge
// build and every open-source edge-tts port: 32 uppercase hex chars.
assert.match(token, /^[0-9A-F]{32}$/);
});
test("resolvePublicCred('edgetts_token') is stable across repeated calls", () => {
// No envName is passed for this key (there's no legacy .env var to migrate
// from — it's a brand-new provider), so it must always resolve to the same
// embedded default rather than reading from process.env.
assert.equal(resolvePublicCred("edgetts_token"), resolvePublicCred("edgetts_token"));
});
// ─── Connection id / message framing ───────────────────────────────────────
test("buildConnectionId returns a 32-char lowercase hex id with no dashes", () => {
const id = buildConnectionId();
assert.match(id, /^[0-9a-f]{32}$/);
});
test("buildConnectionId is unique per call", () => {
const ids = new Set(Array.from({ length: 20 }, () => buildConnectionId()));
assert.equal(ids.size, 20);
});
test("buildSpeechConfigMessage frames a valid speech.config WS text message", () => {
const msg = buildSpeechConfigMessage("Tue, 01 Jan 2026 00:00:00 GMT");
assert.match(msg, /^X-Timestamp:Tue, 01 Jan 2026 00:00:00 GMT\r\n/);
assert.match(msg, /Content-Type:application\/json; charset=utf-8\r\n/);
assert.match(msg, /Path:speech\.config\r\n\r\n/);
const jsonPart = msg.slice(msg.indexOf("\r\n\r\n") + 4);
const parsed = JSON.parse(jsonPart);
assert.equal(
parsed.context.synthesis.audio.outputFormat,
"audio-24khz-48kbitrate-mono-mp3"
);
});
test("buildSsmlMessage frames a valid ssml WS text message carrying the SSML body", () => {
const ssml = buildSsml({ text: "hello" });
const msg = buildSsmlMessage("req-123", ssml, "Tue, 01 Jan 2026 00:00:00 GMT");
assert.match(msg, /^X-RequestId:req-123\r\n/);
assert.match(msg, /Content-Type:application\/ssml\+xml\r\n/);
assert.match(msg, /Path:ssml\r\n\r\n/);
assert.ok(msg.endsWith(ssml), "message must end with the exact SSML payload");
});
test("buildEdgeTtsWsUrl includes TrustedClientToken, Sec-MS-GEC, and ConnectionId", () => {
const url = new URL(buildEdgeTtsWsUrl(Date.UTC(2026, 6, 17)));
assert.equal(url.protocol, "wss:");
assert.equal(url.hostname, "speech.platform.bing.com");
assert.ok(url.searchParams.get("TrustedClientToken"));
assert.match(url.searchParams.get("Sec-MS-GEC") || "", /^[0-9A-F]{64}$/);
assert.match(url.searchParams.get("ConnectionId") || "", /^[0-9a-f]{32}$/);
});
// ─── SSML building / escaping (untrusted-input safety) ─────────────────────
test("escapeSsmlText escapes all five XML special characters", () => {
assert.equal(
escapeSsmlText(`<tag> & "quoted" 'single'`),
"&lt;tag&gt; &amp; &quot;quoted&quot; &apos;single&apos;"
);
});
test("buildSsml embeds escaped text and rejects SSML injection via prosody attrs", () => {
const ssml = buildSsml({
text: "</voice><voice name='evil'>pwned",
rate: "'; </prosody><script>alert(1)</script>",
});
assert.ok(!ssml.includes("<script>"), "malicious prosody rate must be clamped, not embedded");
assert.ok(ssml.includes("&lt;/voice&gt;"), "malicious text must be XML-escaped");
assert.ok(ssml.includes("rate='default'"), "invalid rate falls back to default");
});
test("normalizeEdgeVoice accepts well-formed voice names and rejects everything else", () => {
assert.equal(normalizeEdgeVoice("en-US-AriaNeural"), "en-US-AriaNeural");
assert.equal(normalizeEdgeVoice("pt-BR-FranciscaNeural"), "pt-BR-FranciscaNeural");
assert.equal(normalizeEdgeVoice("not a voice; DROP TABLE"), "en-US-AriaNeural");
assert.equal(normalizeEdgeVoice(undefined), "en-US-AriaNeural");
});
// ─── Binary audio chunk demux (pure, no live socket needed) ────────────────
test("demuxAudioChunk splits a binary frame into headers + raw audio bytes", () => {
const headers = "Path:audio\r\nContent-Type:audio/mpeg\r\n";
const headerBuf = Buffer.from(headers, "ascii");
const audioBuf = Buffer.from([1, 2, 3, 4, 5]);
const lenBuf = Buffer.alloc(2);
lenBuf.writeUInt16BE(headerBuf.length, 0);
const frame = Buffer.concat([lenBuf, headerBuf, audioBuf]);
const result = demuxAudioChunk(frame);
assert.ok(result);
assert.equal(result!.headers, headers);
assert.deepEqual(Array.from(result!.audio), [1, 2, 3, 4, 5]);
});
test("demuxAudioChunk returns null for a truncated/malformed frame", () => {
assert.equal(demuxAudioChunk(Buffer.from([0])), null);
const lenBuf = Buffer.alloc(2);
lenBuf.writeUInt16BE(100, 0); // claims 100 header bytes but frame is short
assert.equal(demuxAudioChunk(Buffer.concat([lenBuf, Buffer.from("short")])), null);
});
test("isTurnEndMessage recognizes the turn.end marker and nothing else", () => {
assert.equal(isTurnEndMessage("X-RequestId:abc\r\nPath:turn.end\r\n\r\n"), true);
assert.equal(isTurnEndMessage("Path:turn.start\r\n\r\n"), false);
assert.equal(isTurnEndMessage(""), false);
});
// ─── Registry wiring ────────────────────────────────────────────────────────
test("edgetts is registered in AUDIO_SPEECH_PROVIDERS with no-key WS transport", () => {
const provider = getSpeechProvider("edgetts");
assert.ok(provider);
assert.equal(provider!.authType, "none");
assert.equal(provider!.format, "edgetts");
assert.match(provider!.baseUrl, /^wss:\/\//);
assert.ok(provider!.models.length > 0);
});
test("parseSpeechModel resolves 'edgetts/<voice>' to the edgetts provider", () => {
const { provider, model } = parseSpeechModel("edgetts/en-US-AriaNeural");
assert.equal(provider, "edgetts");
assert.equal(model, "en-US-AriaNeural");
});
// ─── synthesizeEdgeTts / handleEdgeTtsSpeech with an injected fake WebSocket ─
class FakeEmitterSocket implements MinimalWebSocket {
private listeners: Record<string, ((...args: unknown[]) => void)[]> = {};
sent: string[] = [];
closed = false;
on(event: string, listener: (...args: unknown[]) => void) {
(this.listeners[event] ??= []).push(listener);
}
send(data: string) {
this.sent.push(data);
}
close() {
this.closed = true;
}
emit(event: string, ...args: unknown[]) {
for (const l of this.listeners[event] || []) l(...args);
}
}
test("synthesizeEdgeTts resolves with concatenated audio on turn.end", async () => {
let socket: FakeEmitterSocket;
const Ctor = function (this: unknown, _url: string) {
socket = new FakeEmitterSocket();
// Simulate the server's protocol asynchronously after `open` is sent.
queueMicrotask(() => {
socket.emit("open");
const headers = "Path:audio\r\nContent-Type:audio/mpeg\r\n";
const headerBuf = Buffer.from(headers, "ascii");
const lenBuf = Buffer.alloc(2);
lenBuf.writeUInt16BE(headerBuf.length, 0);
const frame = Buffer.concat([lenBuf, headerBuf, Buffer.from("audiobytes")]);
socket.emit("message", frame, true);
socket.emit("message", "Path:turn.end\r\n\r\n", false);
});
return socket;
} as unknown as new (url: string) => MinimalWebSocket;
const result = await synthesizeEdgeTts({ text: "hello" }, Ctor);
assert.equal(result.audio.toString(), "audiobytes");
assert.equal(result.contentType, "audio/mpeg");
});
test("synthesizeEdgeTts rejects when the socket errors", async () => {
const Ctor = function (this: unknown, _url: string) {
const socket = new FakeEmitterSocket();
queueMicrotask(() => socket.emit("error", new Error("upstream refused connection")));
return socket;
} as unknown as new (url: string) => MinimalWebSocket;
await assert.rejects(
() => synthesizeEdgeTts({ text: "hello" }, Ctor),
/upstream refused connection/
);
});
test("handleEdgeTtsSpeech returns 400 without touching the network on empty input", async () => {
const response = await handleEdgeTtsSpeech({ input: "" });
assert.equal(response.status, 400);
const bodyJson = await response.json();
assert.equal(bodyJson.error.message, "input is required");
});
test("handleEdgeTtsSpeech returns a sanitized 502 on upstream WS failure (Hard Rule #12)", async () => {
const Ctor = function (this: unknown, _url: string) {
const socket = new FakeEmitterSocket();
// Simulate a raw upstream failure that could contain a stack trace or an
// absolute filesystem path — the handler must never leak it verbatim.
const err = new Error("connect ECONNREFUSED 127.0.0.1:443");
(err as Error).stack = `Error: connect ECONNREFUSED\n at /home/user/secret/app.js:42:10`;
queueMicrotask(() => socket.emit("error", err));
return socket;
} as unknown as new (url: string) => MinimalWebSocket;
const response = await handleEdgeTtsSpeech({ input: "hello" }, null, Ctor);
assert.equal(response.status, 502);
const bodyJson = await response.json();
assert.ok(bodyJson.error.message.includes("ECONNREFUSED"));
assert.ok(!bodyJson.error.message.includes("/home/user/secret"), "must not leak a filesystem path");
assert.ok(!bodyJson.error.message.includes("at /"), "must not leak a stack trace frame");
});
test("handleEdgeTtsSpeech enforces the per-IP sliding-window rate limit", async () => {
const ip = `203.0.113.${Math.floor(Math.random() * 250) + 1}`;
// Drain the window with the input-validation fast-path (still exercises
// tryAcquire before validation) using an always-invalid body to avoid a
// real network call, then assert the last call is 429, not 400.
let last: Response | undefined;
for (let i = 0; i < 21; i++) {
last = await handleEdgeTtsSpeech({ input: "" }, ip);
}
assert.equal(last!.status, 429);
const bodyJson = await last!.json();
assert.match(bodyJson.error.message, /rate limit/i);
});