diff --git a/README.md b/README.md index 99ae455229..76c2dee8a1 100644 --- a/README.md +++ b/README.md @@ -557,7 +557,7 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute - **🧠 Memory you control** — off by default, opt-in int8 vector quantization + typed decay, per-request `x-omniroute-no-memory`. → [Memory](docs/frameworks/MEMORY.md) - **🛡️ Security** — prompt-injection guard on every LLM route (red-team suite), opt-in credential-masking guardrail (redacts leaked API keys/secrets in both directions), free DuckDuckGo last-resort web search, and an optional OIDC login gate for the dashboard (password login always stays available). → [Guardrails](docs/security/GUARDRAILS.md) - **🖼️ New endpoints** — `/v1/ocr` (Mistral OCR) and `/v1/audio/translations` (Whisper-style) round out the media surface. → [API Reference](docs/reference/API_REFERENCE.md) -- **🎨 Image / video / audio generation** — one API for media: xAI Grok Imagine & Novita AI video, ComfyUI, Freepik, Adobe Firefly, Microsoft Designer, Segmind, EdgeTTS. → [API Reference](docs/reference/API_REFERENCE.md) +- **🎨 Image / video / audio generation** — one API for media: xAI Grok Imagine & Novita AI video, ComfyUI, Freepik, Adobe Firefly, Microsoft Designer, Segmind, and speech providers such as ElevenLabs. → [API Reference](docs/reference/API_REFERENCE.md) - **🌍 Deployment & ops** — reverse-proxy `basePath`, browser-language auto-detect, per-key device tracking, root-less MITM trust, zh-TW localization. → [Environment](docs/reference/ENVIRONMENT.md) - **🤝 More providers & agents** — Cursor Cloud Agent, Grok Build (xAI) with browser + OAuth login, Ollama first-class card, Claude Opus 5 & Sonnet 5, Kimi official partnership (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… and a refreshed **357-provider catalog**. → [Providers](docs/reference/PROVIDER_REFERENCE.md) - **📡 Routing transparency** — every response carries an `X-OmniRoute-Decision` header naming the strategy/provider/latency that served it, a new `cache-optimized` combo strategy + Auto-Combo `cacheAffinity` factor route repeat requests back to the connection holding the cached prefix, and a read-only `/v1/auto-combo/{channel}/candidates` endpoint exposes an `auto/*` channel's live candidate pool. → [Auto-Combo](docs/routing/AUTO-COMBO.md) diff --git a/changelog.d/maintenance/11711-retire-edge-tts.md b/changelog.d/maintenance/11711-retire-edge-tts.md new file mode 100644 index 0000000000..60ddefe587 --- /dev/null +++ b/changelog.d/maintenance/11711-retire-edge-tts.md @@ -0,0 +1 @@ +- **chore(audio):** retire the built-in EdgeTTS provider and its 11-voice catalog while provenance/licensing review remains on HOLD; the generic `/v1/audio/speech` endpoint and the other speech providers remain available ([#11711](https://github.com/diegosouzapw/OmniRoute/pull/11711)) diff --git a/config/quality/test-masking-allowlist.json b/config/quality/test-masking-allowlist.json index 23780f04b8..dfb9e2d6f9 100644 --- a/config/quality/test-masking-allowlist.json +++ b/config/quality/test-masking-allowlist.json @@ -88,6 +88,10 @@ "tests/unit/gemini-3-5-flash-thinking.test.ts": { "replacement": "tests/unit/model-capabilities-registry.test.ts", "reason": "v3.8.50 back-merge f95b03d7: the provider-neutral Gemini 3.5 Flash tier catalog was RETIRED (MODEL_SPECS entries removed; the thinking tiers now live under antigravity/gemini-3.7-flash-*), so the deleted file's thinking-capable/routing-policy fixtures pinned a retired surface and red-fail against HEAD. The replacement guards the retirement itself (retired ids have no provider-neutral specs) plus resolvable capability floors for the surviving gemini-3-flash-agent id. Verified legitimate, not masking." + }, + "tests/unit/edgetts-provider.test.ts": { + "replacement": "tests/unit/edgetts-retirement.test.ts", + "reason": "v3.8.50 #11711: the EdgeTTS executor and its positive protocol tests were retired together after a provenance/licensing HOLD. The replacement pins the new public contract across the speech registry, handler and UI-derived catalog while preserving gTTS, AWS Polly and ElevenLabs. Feature retirement, not test masking; prune after v3.8.50 merges to main." } }, "tests/unit/catalog-updates-v3x.test.ts": "v3.8.45 #6248: fix(providers) remove deprecated MiMo V2 entries — os 5 asserts removidos pinavam specs de modelos mimo-v2-* que deixaram de existir no catálogo (54→49). Asserts seguem a remoção dos modelos, não enfraquecimento. Verificado legítimo. Prune após v3.8.45 mergear para main.", diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index 790cf35de7..461d64fe3a 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -27,7 +27,7 @@ Core capabilities: - Embedding generation via `/v1/embeddings` (6 providers, 9 models) - Image generation via `/v1/images/generations` (10+ providers, 20+ models) - Audio transcription via `/v1/audio/transcriptions` (7 providers) -- Text-to-speech via `/v1/audio/speech` (10 providers) +- Text-to-speech via `/v1/audio/speech` (24 built-in providers) - Video generation via `/v1/videos/generations` (ComfyUI + SD WebUI) - Music generation via `/v1/music/generations` (ComfyUI) - Web search via `/v1/search` (5 providers) @@ -980,7 +980,7 @@ All other providers (including custom compatible nodes) use the `DefaultExecutor | KIE | openai | API Key | ✅ | ✅ | ❌ | ❌ | | Devin Desktop | openai | Imported API key | ✅ (Connect→SSE) | ✅ | ❌ | ⚠️ Per request | | GitLab Duo | openai | OAuth (GitLab) | ✅ | ✅ | ✅ | ❌ | -| Devin CLI | openai | Local CLI login | ✅ | ✅ | ❌ | ✅ Task API | +| Devin CLI | openai | Local CLI login | ✅ | ✅ | ❌ | ✅ Task API | | Codex Cloud | openai-responses | OAuth | ✅ | ❌ | ✅ | ✅ Rate limits | | Jules | openai | OAuth | ✅ | ✅ | ✅ | ✅ Task API | | AgentRouter | openai | API Key | ✅ | ✅ | ❌ | ❌ | diff --git a/docs/guides/USER_GUIDE.md b/docs/guides/USER_GUIDE.md index 516fd75dc2..fc4ef0f27b 100644 --- a/docs/guides/USER_GUIDE.md +++ b/docs/guides/USER_GUIDE.md @@ -1027,7 +1027,6 @@ If only OpenRouter is configured, use `openrouter/deepgram/nova-3`. - `kie/` - `aws-polly/` - `xiaomi-mimo/` -- `edgetts/` (Microsoft Edge "Read Aloud" — free, no API key; unofficial/reverse-engineered endpoint) - `coqui/`, `tortoise/` - `qwen/` diff --git a/docs/i18n/it/README.md b/docs/i18n/it/README.md index 97543d6716..6dbc39f5a1 100644 --- a/docs/i18n/it/README.md +++ b/docs/i18n/it/README.md @@ -486,7 +486,7 @@ Radar è opt-in e usa soltanto richieste GET. Il client OmniRoute non carica pro - **🧠 Memoria sotto il tuo controllo** — disattivata per impostazione predefinita, quantizzazione vettoriale int8 opt-in + decadimento tipizzato, `x-omniroute-no-memory` per-request. → [Memoria](../../frameworks/MEMORY.md) - **🛡️ Sicurezza** — guard contro la prompt injection su ogni route LLM (suite red-team), guardrail opzionale per il masking delle credenziali (oscura API key/secret trapelati in entrambe le direzioni), web search DuckDuckGo gratuita come ultima risorsa e gate di login OIDC opzionale per la dashboard (il login con password resta sempre disponibile). → [Guardrail](../../security/GUARDRAILS.md) - **🖼️ Nuovi endpoint** — `/v1/ocr` (Mistral OCR) e `/v1/audio/translations` (stile Whisper) completano la superficie media. → [Riferimento API](../../reference/API_REFERENCE.md) -- **🎨 Generazione immagini / video / audio** — una sola API per i media: xAI Grok Imagine e Novita AI video, ComfyUI, Freepik, Adobe Firefly, Microsoft Designer, Segmind, EdgeTTS. → [Riferimento API](../../reference/API_REFERENCE.md) +- **🎨 Generazione immagini / video / audio** — una sola API per i media: xAI Grok Imagine e Novita AI video, ComfyUI, Freepik, Adobe Firefly, Microsoft Designer, Segmind e provider vocali come ElevenLabs. → [Riferimento API](../../reference/API_REFERENCE.md) - **🌍 Deployment e operazioni** — `basePath` del reverse proxy, rilevamento automatico della lingua del browser, tracking dei dispositivi per chiave, trust MITM senza root, localizzazione zh-TW. → [Ambiente](../../reference/ENVIRONMENT.md) - **🤝 Più provider e agenti** — Cursor Cloud Agent, Grok Build (xAI) con login browser + OAuth, scheda Ollama di prima classe, Claude Opus 5 e Sonnet 5, partnership ufficiale Kimi (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… e un catalogo aggiornato di **350 provider**. → [Provider](../../reference/PROVIDER_REFERENCE.md) - **📡 Trasparenza del routing** — ogni risposta include un header `X-OmniRoute-Decision` con strategia/provider/latenza che l'ha servita; una nuova strategia combo `cache-optimized` + il fattore `cacheAffinity` di Auto-Combo riportano le richieste ripetute alla connessione che possiede il prefisso in cache; un endpoint read-only `/v1/auto-combo/{channel}/candidates` espone il pool di candidati live di un canale `auto/*`. → [Auto-Combo](../../routing/AUTO-COMBO.md) diff --git a/docs/i18n/pl/docs/guides/USER_GUIDE.md b/docs/i18n/pl/docs/guides/USER_GUIDE.md index 147d7b82a9..0dac525ddb 100644 --- a/docs/i18n/pl/docs/guides/USER_GUIDE.md +++ b/docs/i18n/pl/docs/guides/USER_GUIDE.md @@ -973,7 +973,6 @@ curl -X POST http://localhost:20128/v1/audio/transcriptions \ - `kie/` - `aws-polly/` - `xiaomi-mimo/` -- `edgetts/` (Microsoft Edge „Read Aloud” — darmowe, bez klucza API; nieoficjalny/reverse-engineered endpoint) - `coqui/`, `tortoise/` - `qwen/` diff --git a/docs/i18n/tr/README.md b/docs/i18n/tr/README.md index 5a0998e1cf..34a80324c5 100644 --- a/docs/i18n/tr/README.md +++ b/docs/i18n/tr/README.md @@ -548,7 +548,7 @@ Radar isteğe bağlıdır (opt-in) ve yalnızca GET istekleri yapar. OmniRoute i - **🧠 Kontrol ettiğiniz bellek** — Varsayılan olarak kapalı, isteğe bağlı int8 vektör niceleme + tipli sönümleme, istek başına `x-omniroute-no-memory`. → [Bellek](docs/frameworks/MEMORY.md) - **🛡️ Güvenlik** — Her LLM rotasında istem enjeksiyonu koruması (red-team paketi), isteğe bağlı kimlik bilgisi maskeleme koruması (her iki yönde de sızan API anahtarlarını/gizli bilgileri sansürler), ücretsiz DuckDuckGo son çare web araması ve pano için isteğe bağlı OIDC giriş kapısı (şifreyle giriş her zaman kullanılabilir kalır). → [Güvenlik Önlemleri (Guardrails)](docs/security/GUARDRAILS.md) - **🖼️ Yeni uç noktalar** — `/v1/ocr` (Mistral OCR) ve `/v1/audio/translations` (Whisper tarzı) medya yüzeyini tamamlar. → [API Referansı](docs/reference/API_REFERENCE.md) -- **🎨 Görsel / video / ses üretimi** — Medya için tek bir API: xAI Grok Imagine ve Novita AI video, ComfyUI, Freepik, Adobe Firefly, Microsoft Designer, Segmind, EdgeTTS. → [API Referansı](docs/reference/API_REFERENCE.md) +- **🎨 Görsel / video / ses üretimi** — Medya için tek bir API: xAI Grok Imagine ve Novita AI video, ComfyUI, Freepik, Adobe Firefly, Microsoft Designer, Segmind ve ElevenLabs gibi konuşma sağlayıcıları. → [API Referansı](docs/reference/API_REFERENCE.md) - **🌍 Dağıtım ve operasyonlar** — Ters proxy `basePath`, tarayıcı dili otomatik algılama, anahtar başına cihaz takibi, root gerektirmeyen MITM güveni, zh-TW yerelleştirmesi. → [Ortam Değişkenleri](docs/reference/ENVIRONMENT.md) - **🤝 Daha fazla sağlayıcı ve ajan** — Cursor Cloud Agent, tarayıcı + OAuth girişiyle Grok Build (xAI), Ollama birinci sınıf kartı, Claude Opus 5 ve Sonnet 5, Kimi resmi ortaklığı (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… ve yenilenen **350 sağlayıcılı katalog**. → [Sağlayıcılar](docs/reference/PROVIDER_REFERENCE.md) - **📡 Yönlendirme şeffaflığı** — Her yanıt, isteğe hizmet veren stratejiyi/sağlayıcıyı/gecikmeyi belirten bir `X-OmniRoute-Decision` başlığı taşır, yeni bir `cache-optimized` kombo stratejisi + Auto-Combo `cacheAffinity` faktörü yinelenen istekleri önbelleğe alınmış öneki tutan bağlantıya geri yönlendirir ve salt okunur bir `/v1/auto-combo/{channel}/candidates` uç noktası bir `auto/*` kanalının canlı aday havuzunu gösterir. → [Auto-Combo](docs/routing/AUTO-COMBO.md) diff --git a/docs/i18n/zh-TW/docs/guides/USER_GUIDE.md b/docs/i18n/zh-TW/docs/guides/USER_GUIDE.md index 0a579e6e54..26725bb477 100644 --- a/docs/i18n/zh-TW/docs/guides/USER_GUIDE.md +++ b/docs/i18n/zh-TW/docs/guides/USER_GUIDE.md @@ -973,7 +973,6 @@ curl -X POST http://localhost:20128/v1/audio/transcriptions \ - `kie/` - `aws-polly/` - `xiaomi-mimo/` -- `edgetts/`(Microsoft Edge「朗讀功能」— 免費,無需 API 金鑰;非官方/逆向工程端點) - `coqui/`, `tortoise/` - `qwen/` diff --git a/open-sse/config/audioRegistry.ts b/open-sse/config/audioRegistry.ts index 9cd89cb769..651df358dd 100644 --- a/open-sse/config/audioRegistry.ts +++ b/open-sse/config/audioRegistry.ts @@ -544,31 +544,6 @@ export const AUDIO_SPEECH_PROVIDERS: Record = { ], }, - 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)" }, - ], - }, - gtts: { id: "gtts", // Google Translate TTS — reverse-engineered, no API key required. diff --git a/open-sse/executors/awsPollyTts.ts b/open-sse/executors/awsPollyTts.ts index 311b2148dd..4bbff25a1e 100644 --- a/open-sse/executors/awsPollyTts.ts +++ b/open-sse/executors/awsPollyTts.ts @@ -2,9 +2,8 @@ * 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. + * file-size ratchet baseline — config/quality/file-size-baseline.json). + * 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 diff --git a/open-sse/executors/edgeTts.ts b/open-sse/executors/edgeTts.ts deleted file mode 100644 index 18a9f144e9..0000000000 --- a/open-sse/executors/edgeTts.ts +++ /dev/null @@ -1,352 +0,0 @@ -/** - * 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 `` element. */ -export function escapeSsmlText(text: string): string { - return String(text ?? "") - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -/** 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 ( - `` + - `` + - `${text}` + - `` - ); -} - -/** 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 { - const Ctor = WebSocketCtor ?? ((await import("ws")).default as unknown as WebSocketCtor); - const url = buildEdgeTtsWsUrl(); - const ssml = buildSsml(input); - const requestId = buildConnectionId(); - - return new Promise((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 { - 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)}` - ); - } -} diff --git a/open-sse/executors/gtts.ts b/open-sse/executors/gtts.ts index ad70feba9d..113e44ec9b 100644 --- a/open-sse/executors/gtts.ts +++ b/open-sse/executors/gtts.ts @@ -3,7 +3,7 @@ * * Reverse-engineered, unofficial, undocumented endpoint (not a published * Google public API) — the same class of integration this codebase already - * accepts for other "-web"/no-auth style providers (edgeTts.ts, chipotle.ts). + * accepts for other "-web"/no-auth style providers. * No user account/API key is required. * * The issue's originally proposed endpoint diff --git a/open-sse/handlers/audioSpeech.ts b/open-sse/handlers/audioSpeech.ts index efcf369cf9..9bb65cd342 100644 --- a/open-sse/handlers/audioSpeech.ts +++ b/open-sse/handlers/audioSpeech.ts @@ -23,7 +23,6 @@ import { kieExecutor } from "../executors/kie.ts"; import { vertexGenerateSpeech } from "../executors/vertexMedia.ts"; import { handleGeminiTtsSpeech } from "../executors/geminiTts.ts"; import { handleAwsPollySpeech } from "../executors/awsPollyTts.ts"; -import { handleEdgeTtsSpeech } from "../executors/edgeTts.ts"; import { GttsUpstreamError, normalizeGttsLang, synthesizeGtts } from "../executors/gtts.ts"; import { errorResponse } from "../utils/error.ts"; import { resolveElevenLabsVoiceId } from "./elevenLabsVoiceMap.ts"; @@ -844,7 +843,6 @@ export async function handleAudioSpeech({ credentials, resolvedProvider = null, resolvedModel = null, - clientIp = null, }) { if (!body.model) { return errorResponse(400, "model is required"); @@ -866,7 +864,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, fishaudio, playht, kie, aws-polly, xiaomi-mimo, edgetts, gtts, coqui, tortoise, qwen` + `No speech provider found for model "${body.model}". Use format provider/model. Available: openai, hyperbolic, deepgram, nvidia, elevenlabs, huggingface, inworld, cartesia, fishaudio, playht, kie, aws-polly, xiaomi-mimo, gtts, coqui, tortoise, qwen` ); } @@ -946,10 +944,6 @@ export async function handleAudioSpeech({ return handleAwsPollySpeech(providerConfig, body, modelId, token, credentials); } - if (providerConfig.format === "edgetts") { - return handleEdgeTtsSpeech(body, clientIp); - } - if (providerConfig.format === "gtts") { return handleGttsSpeech(body); } diff --git a/open-sse/services/speechCombo.ts b/open-sse/services/speechCombo.ts index 1d73337784..b12c0ab605 100644 --- a/open-sse/services/speechCombo.ts +++ b/open-sse/services/speechCombo.ts @@ -24,7 +24,6 @@ import { handleAudioSpeech } from "@omniroute/open-sse/handlers/audioSpeech.ts"; import { attachOmniRouteMetaToResponse } from "@/domain/omnirouteResponseMeta"; import { generateRequestId } from "@/shared/utils/requestId"; import { calculateModalCost } from "@/lib/usage/costCalculator"; -import { getClientIpFromRequest } from "@/lib/ipUtils"; import { toJsonErrorPayload } from "@/shared/utils/upstreamError"; import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; @@ -35,10 +34,6 @@ import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; export async function executeSpeechCombo( comboName: string, body: Record, - auth: { - request: Request; - policy: { apiKeyInfo?: { id?: string; name?: string } | null }; - }, startTime: number ): Promise { const combo = await getComboByName(comboName); @@ -80,7 +75,6 @@ export async function executeSpeechCombo( ); } - const clientIp = getClientIpFromRequest(auth.request); let lastError: { status: number; error: string } | null = null; let fallbackCount = 0; @@ -129,7 +123,6 @@ export async function executeSpeechCombo( credentials, resolvedProvider: providerConfig, resolvedModel, - clientIp, }); if (response?.ok) { diff --git a/open-sse/utils/publicCreds.ts b/open-sse/utils/publicCreds.ts index ad915e4ce2..d0bbe7ff26 100644 --- a/open-sse/utils/publicCreds.ts +++ b/open-sse/utils/publicCreds.ts @@ -186,15 +186,6 @@ const EMBEDDED_DEFAULTS = { 12, 93, 15, 11, 74, 12, 16, 77, 72, 72, 73, 20, 82, 65, 93, 81, 72, 65, 28, 13, 93, 88, 93, 95, 92, 70, 16, 81, 31, 66, 17, 4, 88, 88, 5, 28, ], - // 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, - ], // Adobe Firefly web (firefly.adobe.com) — public x-api-key + IMS client_id // (`clio-playground-web`). Captured from live browser generate/discovery calls. // Not a per-user secret; every Firefly SPA session sends the same value. diff --git a/src/app/api/v1/audio/speech/route.ts b/src/app/api/v1/audio/speech/route.ts index 799a933fed..686a5abd90 100644 --- a/src/app/api/v1/audio/speech/route.ts +++ b/src/app/api/v1/audio/speech/route.ts @@ -18,7 +18,6 @@ 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 @@ -64,7 +63,7 @@ async function postHandler(request, context) { const combo = await getComboByName(body.model); if (combo) { const { executeSpeechCombo } = await import("@omniroute/open-sse/services/speechCombo"); - return executeSpeechCombo(body.model, body, { request, policy }, startTime); + return executeSpeechCombo(body.model, body, startTime); } } @@ -102,7 +101,6 @@ async function postHandler(request, context) { credentials, resolvedProvider: providerConfig, resolvedModel, - clientIp: getClientIpFromRequest(request), }); if (response?.ok) { await clearRecoveredProviderState(credentials); diff --git a/stryker.conf.json b/stryker.conf.json index ee0e522b8d..29af9b4977 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -230,7 +230,6 @@ "tests/unit/db/stats-dbstat-optional.test.ts", "tests/unit/ddg-circuit-breaker-null-content-6999-7000.test.ts", "tests/unit/domain-persistence.test.ts", - "tests/unit/edgetts-provider.test.ts", "tests/unit/embedding-account-cooldown-10347.test.ts", "tests/unit/embedding-cooldown-integration-10347.test.ts", "tests/unit/embeddings-auth.test.ts", diff --git a/tests/unit/combo/speech-combo.test.ts b/tests/unit/combo/speech-combo.test.ts index d011de5ba4..4bd415d830 100644 --- a/tests/unit/combo/speech-combo.test.ts +++ b/tests/unit/combo/speech-combo.test.ts @@ -22,21 +22,6 @@ const core = await import("@/lib/db/core.ts"); const { createCombo } = await import("@/lib/db/combos"); const { executeSpeechCombo } = await import("@omniroute/open-sse/services/speechCombo"); -function createRequest(model: string): Request { - return new Request("http://localhost:20128/v1/audio/speech", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model, input: "hello there" }), - }); -} - -function createMockAuth() { - return { - request: createRequest("test-combo"), - policy: { apiKeyInfo: { id: "test-key", name: "test-key" } }, - }; -} - async function cleanupTestDataDir() { let lastError: unknown; for (let attempt = 0; attempt < 5; attempt += 1) { @@ -66,7 +51,6 @@ test("returns 400 when combo is not found", async () => { const response = await executeSpeechCombo( "nonexistent-combo", { model: "nonexistent-combo", input: "hello there" }, - createMockAuth(), Date.now() ); assert.equal(response.status, 400); @@ -84,7 +68,6 @@ test("returns 400 when combo has no speech-capable targets", async () => { const response = await executeSpeechCombo( "chat-only-combo", { model: "chat-only-combo", input: "hello there" }, - createMockAuth(), Date.now() ); assert.equal(response.status, 400); @@ -93,13 +76,31 @@ test("returns 400 when combo has no speech-capable targets", async () => { assert.ok(!bodyStr.includes("at "), "Error response does not leak stack traces"); }); +test("does not select retired EdgeTTS targets as speech-capable", async () => { + await createCombo({ + name: "retired-edgetts-combo", + strategy: "priority", + models: ["edgetts/en-US-AriaNeural"], + }); + + const response = await executeSpeechCombo( + "retired-edgetts-combo", + { model: "retired-edgetts-combo", input: " " }, + Date.now() + ); + const bodyStr = JSON.stringify(await response.json()); + + assert.equal(response.status, 400); + assert.ok(bodyStr.includes("No speech-capable targets")); + assert.ok(!bodyStr.includes("at "), "Error response does not leak stack traces"); +}); + test("returns 400 when combo has no usable targets", async () => { await createCombo({ name: "empty-combo", strategy: "priority", models: [] }); const response = await executeSpeechCombo( "empty-combo", { model: "empty-combo", input: "hello there" }, - createMockAuth(), Date.now() ); assert.equal(response.status, 400); @@ -115,7 +116,6 @@ test("fails cleanly when speech targets exist but no provider connection does", const response = await executeSpeechCombo( "spc-no-conn", { model: "spc-no-conn", input: "hello there" }, - createMockAuth(), Date.now() ); assert.ok(response.status >= 400, "Surfaces a failure rather than a fake success"); diff --git a/tests/unit/edgetts-provider.test.ts b/tests/unit/edgetts-provider.test.ts deleted file mode 100644 index e2339f8ca9..0000000000 --- a/tests/unit/edgetts-provider.test.ts +++ /dev/null @@ -1,294 +0,0 @@ -// 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(` & "quoted" 'single'`), - "<tag> & "quoted" 'single'" - ); -}); - -test("buildSsml embeds escaped text and rejects SSML injection via prosody attrs", () => { - const ssml = buildSsml({ - text: "pwned", - rate: "'; ", - }); - assert.ok(!ssml.includes("