From 38dd62819b3788a5340dbb70ed1423a0bbca1e44 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:16:58 -0300 Subject: [PATCH] feat(providers): Speechmatics STT, gTTS, VibeProxy preset (#6659, #6667, #6874) (#7655) Validated in merge-train --fast @ 6cafcbb (static gates + 9 changed test files + vitest green, 2m35s; full suite ran today on train 2c tip) --- .../6659-speechmatics-stt-provider.md | 1 + .../features/6667-gtts-audio-tts-provider.md | 1 + ...4-vibeproxy-openai-provider-node-preset.md | 1 + docs/reference/PROVIDER_REFERENCE.md | 9 +- open-sse/config/audioRegistry.ts | 29 +++ open-sse/executors/gtts.ts | 214 ++++++++++++++++++ open-sse/handlers/audioSpeech.ts | 29 ++- open-sse/handlers/audioTranscription.ts | 104 ++++++++- src/app/api/provider-nodes/route.ts | 60 ++++- src/shared/constants/providers/audio.ts | 12 + src/shared/validation/schemas/provider.ts | 38 +++- tests/unit/gtts-provider.test.ts | 187 +++++++++++++++ .../provider-nodes-vibeproxy-preset.test.ts | 135 +++++++++++ ...peechmatics-transcription-provider.test.ts | 143 ++++++++++++ 14 files changed, 951 insertions(+), 12 deletions(-) create mode 100644 changelog.d/features/6659-speechmatics-stt-provider.md create mode 100644 changelog.d/features/6667-gtts-audio-tts-provider.md create mode 100644 changelog.d/features/6874-vibeproxy-openai-provider-node-preset.md create mode 100644 open-sse/executors/gtts.ts create mode 100644 tests/unit/gtts-provider.test.ts create mode 100644 tests/unit/provider-nodes-vibeproxy-preset.test.ts create mode 100644 tests/unit/speechmatics-transcription-provider.test.ts diff --git a/changelog.d/features/6659-speechmatics-stt-provider.md b/changelog.d/features/6659-speechmatics-stt-provider.md new file mode 100644 index 0000000000..e19ecb31b8 --- /dev/null +++ b/changelog.d/features/6659-speechmatics-stt-provider.md @@ -0,0 +1 @@ +- **feat(providers):** add Speechmatics as an STT provider — async batch transcription (Enhanced operating point), 8 hours/month free tier, no credit card required. Streaming (real-time) mode is out of scope for v1. (#6659) diff --git a/changelog.d/features/6667-gtts-audio-tts-provider.md b/changelog.d/features/6667-gtts-audio-tts-provider.md new file mode 100644 index 0000000000..3c55e36deb --- /dev/null +++ b/changelog.d/features/6667-gtts-audio-tts-provider.md @@ -0,0 +1 @@ +- **feat(providers):** add gTTS (Google Translate TTS) as a free, no-signup audio-speech provider — routes through Google's current `batchexecute` RPC endpoint (the previously proposed `translate_tts` endpoint is deprecated), splitting input at the 100-char-per-request limit. (#6667) diff --git a/changelog.d/features/6874-vibeproxy-openai-provider-node-preset.md b/changelog.d/features/6874-vibeproxy-openai-provider-node-preset.md new file mode 100644 index 0000000000..8a55e40627 --- /dev/null +++ b/changelog.d/features/6874-vibeproxy-openai-provider-node-preset.md @@ -0,0 +1 @@ +- **feat(providers):** add a `vibeproxy-openai` provider-node preset to `POST /api/provider-nodes` — defaults name/prefix/apiType for VibeProxy's local OpenAI-compatible gateway and normalizes the caller-supplied base URL to its `/v1` root; `baseUrl` remains mandatory. (#6874, idea from #6137 by @KooshaPari) diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md index 74298874cc..73532665cf 100644 --- a/docs/reference/PROVIDER_REFERENCE.md +++ b/docs/reference/PROVIDER_REFERENCE.md @@ -1,16 +1,16 @@ --- title: "Provider Reference" version: 3.8.49 -lastUpdated: 2026-07-17 +lastUpdated: 2026-07-18 --- # Provider Reference > **Auto-generated** from `src/shared/constants/providers.ts` — do not edit by hand. > Regenerate with: `npm run gen:provider-reference` -> **Last generated:** 2026-07-17 +> **Last generated:** 2026-07-18 -Total providers: **264**. See category breakdown below. +Total providers: **265**. See category breakdown below. ## Categories @@ -305,7 +305,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `tavily-search` | `tavily-search` | Tavily Search | Search | [link](https://tavily.com) | API key from app.tavily.com (format: tvly-...) | | `youcom-search` | `youcom-search` | You.com Search | Search | [link](https://you.com/business/api/) | X-API-Key from the You.com platform dashboard | -## Audio-only Providers (9) +## Audio-only Providers (10) | ID | Alias | Name | Tags | Website | Notes | |----|-------|------|------|---------|-------| @@ -318,6 +318,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `inworld` | `inworld` | Inworld | Audio | [link](https://inworld.ai) | — | | `playht` | `playht` | PlayHT | Audio | [link](https://play.ht) | — | | `rev-ai` | `revai` | Rev AI | Audio | [link](https://www.rev.ai) | — | +| `speechmatics` | `sm` | Speechmatics | Audio | [link](https://www.speechmatics.com) | Free tier — 8 hours/month, no credit card required. Batch (async) mode only. | ## Upstream Proxy Providers (2) diff --git a/open-sse/config/audioRegistry.ts b/open-sse/config/audioRegistry.ts index 117e9a4b52..3f56c66ec8 100644 --- a/open-sse/config/audioRegistry.ts +++ b/open-sse/config/audioRegistry.ts @@ -189,6 +189,21 @@ export const AUDIO_TRANSCRIPTION_PROVIDERS: Record = { { id: "fusion", name: "Fusion ASR" }, ], }, + + speechmatics: { + id: "speechmatics", + // POST https://asr.api.speechmatics.com/v2/jobs — async batch workflow: + // submit multipart job (audio + JSON config) → poll → fetch transcript. + // Auth: Authorization: Bearer + // Free tier: 8 hours/month, no credit card required. + // Streaming (WebSocket real-time) mode is out of scope for v1 — batch only. + baseUrl: "https://asr.api.speechmatics.com/v2/jobs", + authType: "apikey", + authHeader: "bearer", + async: true, + format: "speechmatics", + models: [{ id: "enhanced", name: "Enhanced" }], + }, }; /** @@ -460,6 +475,20 @@ export const AUDIO_SPEECH_PROVIDERS: Record = { ], }, + gtts: { + id: "gtts", + // Google Translate TTS — reverse-engineered, no API key required. + // POST batchexecute RPC (unlike the deprecated GET /translate_tts) — + // handled by open-sse/executors/gtts.ts, dispatched via the "gtts" format. + // No official SLA; per-IP rate-limited by Google without notice. + baseUrl: "https://translate.google.com/_/TranslateWebserverUi/data/batchexecute", + authType: "none", + authHeader: "none", + format: "gtts", + supportedFormats: ["mp3"], + models: [{ id: "default", name: "Google Translate TTS (Free)" }], + }, + "xiaomi-mimo": { id: "xiaomi-mimo", baseUrl: "https://api.xiaomimimo.com/v1/chat/completions", diff --git a/open-sse/executors/gtts.ts b/open-sse/executors/gtts.ts new file mode 100644 index 0000000000..a72c44b2f7 --- /dev/null +++ b/open-sse/executors/gtts.ts @@ -0,0 +1,214 @@ +/** + * gTTS — Google Translate text-to-speech (#6667). + * + * 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). + * No user account/API key is required. + * + * The issue's originally proposed endpoint + * (`https://translate.google.com/translate_tts`, GET with `q`/`tl`/`ie` query + * params) has been deprecated by Google. The current, working mechanism — + * verified directly against `pndurette/gTTS`'s `gtts/tts.py` source — is a + * POST RPC call to Google's internal `batchexecute` endpoint: + * + * POST https://translate.google./_/TranslateWebserverUi/data/batchexecute + * Content-Type: application/x-www-form-urlencoded;charset=utf-8 + * Body: f.req= + * + * The envelope wraps `[text, lang, true, "null"]` under RPC id `"jQ1olc"`: + * f.req = [[["jQ1olc", '["","",true,"null"]', null, "generic"]]] + * + * There is a hard 100-character-per-request limit on `text` — longer input + * must be split into multiple RPC calls and the resulting MP3 byte chunks + * concatenated (§ `chunkGttsText`). + * + * The response is a `)]}'`-prefixed "batchexecute" payload; the base64 audio + * lives inside the entry whose outer array starts with `["wrb.fr","jQ1olc",…]` + * (§ `parseBatchExecuteResponse`). + * + * All parsing/chunking above is implemented as pure functions so it can be + * unit-tested without a live upstream connection — only `synthesizeGtts()` + * itself touches the network, and it accepts an injectable `fetch` for tests. + */ + +/** Hard per-request character limit enforced by Google's batchexecute endpoint. */ +export const GOOGLE_TTS_MAX_CHARS = 100; + +const GTTS_RPC_ID = "jQ1olc"; +const GTTS_USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; +const GTTS_REFERER = "http://translate.google.com/"; +const DEFAULT_LANG = "en"; +const DEFAULT_TLD = "com"; +/** Only allow simple BCP-47-ish language codes to keep this untrusted input from injecting RPC payload structure. */ +const LANG_PATTERN = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})?$/; + +export class GttsUpstreamError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.name = "GttsUpstreamError"; + this.status = status; + } +} + +export interface GttsSynthInput { + text: string; + lang?: string; + tld?: string; +} + +/** Normalize a caller-supplied language code, falling back to English. */ +export function normalizeGttsLang(lang: unknown): string { + const value = typeof lang === "string" ? lang.trim() : ""; + return LANG_PATTERN.test(value) ? value : DEFAULT_LANG; +} + +/** + * Split `text` into chunks respecting Google's 100-character-per-request + * limit, preferring to break on whitespace so words are not split mid-token. + * A single "word" longer than `maxChars` is hard-split as a last resort. + */ +export function chunkGttsText(text: unknown, maxChars: number = GOOGLE_TTS_MAX_CHARS): string[] { + const trimmed = typeof text === "string" ? text.trim() : ""; + if (!trimmed) return []; + if (trimmed.length <= maxChars) return [trimmed]; + + const chunks: string[] = []; + let remaining = trimmed; + while (remaining.length > maxChars) { + let splitAt = -1; + for (let i = maxChars; i > 0; i--) { + if (/\s/.test(remaining[i])) { + splitAt = i; + break; + } + } + if (splitAt <= 0) splitAt = maxChars; + chunks.push(remaining.slice(0, splitAt).trim()); + remaining = remaining.slice(splitAt).trim(); + } + if (remaining) chunks.push(remaining); + return chunks.filter((c) => c.length > 0); +} + +/** Build the `f.req=`-prefixed, urlencoded RPC body for one text chunk. */ +export function buildGttsRpcBody(text: string, lang: string): string { + const innerPayload = JSON.stringify([text, lang, true, "null"]); + const envelope = [[[GTTS_RPC_ID, innerPayload, null, "generic"]]]; + return `f.req=${encodeURIComponent(JSON.stringify(envelope))}&`; +} + +/** + * Extract the base64 audio payload from one `["wrb.fr","jQ1olc",…]` entry, + * or `null` if this entry isn't a matching audio fragment. + */ +function extractAudioFromWrbFrEntry(entry: unknown): string | null { + if (!Array.isArray(entry) || entry[0] !== "wrb.fr" || entry[1] !== GTTS_RPC_ID) return null; + if (typeof entry[2] !== "string") return null; + + try { + const inner = JSON.parse(entry[2]); + if (Array.isArray(inner) && typeof inner[0] === "string" && inner[0].length > 0) { + return inner[0]; + } + } catch { + // Not a JSON-parseable payload — treat as "no audio in this entry". + } + return null; +} + +/** Parse one newline-delimited JSON fragment, returning its audio payload if present. */ +function findAudioInBatchExecuteLine(line: string): string | null { + let outer: unknown; + try { + outer = JSON.parse(line); + } catch { + return null; + } + if (!Array.isArray(outer)) return null; + + for (const entry of outer) { + const audio = extractAudioFromWrbFrEntry(entry); + if (audio) return audio; + } + return null; +} + +/** + * Extract the base64-encoded audio payload from a `batchexecute` response. + * The response is `)]}'`-prefixed, followed by newline-delimited JSON + * fragments interleaved with numeric length-prefix lines; the audio lives + * in the fragment whose entry starts with `["wrb.fr","jQ1olc",…]`. + */ +export function parseBatchExecuteResponse(raw: string): string { + const cleaned = typeof raw === "string" ? raw.replace(/^\)\]\}'\n?/, "") : ""; + const lines = cleaned.split("\n").filter((line) => { + const trimmedLine = line.trim(); + return trimmedLine.length > 0 && !/^\d+$/.test(trimmedLine); + }); + + for (const line of lines) { + const audio = findAudioInBatchExecuteLine(line); + if (audio) return audio; + } + + throw new GttsUpstreamError(502, "gTTS response did not contain audio data"); +} + +type FetchLike = (url: string, init: RequestInit) => Promise; + +/** Synthesize one ≤100-char chunk, returning the decoded MP3 bytes. */ +async function synthesizeGttsChunk( + chunk: string, + lang: string, + tld: string, + fetchImpl: FetchLike +): Promise { + const body = buildGttsRpcBody(chunk, lang); + const res = await fetchImpl( + `https://translate.google.${tld}/_/TranslateWebserverUi/data/batchexecute`, + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded;charset=utf-8", + "User-Agent": GTTS_USER_AGENT, + Referer: GTTS_REFERER, + }, + body, + } + ); + + if (!res.ok) { + const errText = await res.text().catch(() => ""); + throw new GttsUpstreamError(res.status, errText || `gTTS upstream error (${res.status})`); + } + + const raw = await res.text(); + const base64Audio = parseBatchExecuteResponse(raw); + return Buffer.from(base64Audio, "base64"); +} + +/** + * Synthesize `input.text` end-to-end: chunk to Google's 100-char limit, + * POST each chunk to the batchexecute RPC endpoint, and concatenate the + * decoded MP3 byte chunks into one buffer. + */ +export async function synthesizeGtts( + input: GttsSynthInput, + fetchImpl: FetchLike = fetch +): Promise { + const lang = normalizeGttsLang(input.lang); + const tld = (typeof input.tld === "string" && input.tld.trim()) || DEFAULT_TLD; + const chunks = chunkGttsText(input.text); + if (chunks.length === 0) { + throw new GttsUpstreamError(400, "gTTS requires non-empty input text"); + } + + const buffers: Buffer[] = []; + for (const chunk of chunks) { + buffers.push(await synthesizeGttsChunk(chunk, lang, tld, fetchImpl)); + } + return Buffer.concat(buffers); +} diff --git a/open-sse/handlers/audioSpeech.ts b/open-sse/handlers/audioSpeech.ts index d7053a1b09..f885a59e69 100644 --- a/open-sse/handlers/audioSpeech.ts +++ b/open-sse/handlers/audioSpeech.ts @@ -23,6 +23,7 @@ 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 { GttsUpstreamError, normalizeGttsLang, synthesizeGtts } from "../executors/gtts.ts"; import { errorResponse } from "../utils/error.ts"; import { audioStreamResponse, upstreamErrorResponse } from "../utils/audioResponse.ts"; import { @@ -725,6 +726,28 @@ async function handleTortoiseSpeech(providerConfig, body) { }); } +/** + * Handle gTTS TTS (local no-auth, Google Translate batchexecute RPC). + * `voice` doubles as the language code since gTTS has no voice concept — + * defaults to English when omitted or unrecognized. + */ +async function handleGttsSpeech(body) { + try { + const audio = await synthesizeGtts({ + text: body.input, + lang: normalizeGttsLang(body.voice), + }); + return new Response(audio, { + status: 200, + headers: { ...CORS_HEADERS, "Content-Type": "audio/mpeg" }, + }); + } catch (err) { + const status = err instanceof GttsUpstreamError ? err.status : 502; + const message = err instanceof Error ? err.message : "gTTS synthesis failed"; + return errorResponse(status, message); + } +} + /** * Handle audio speech (TTS) request * @@ -761,7 +784,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, edgetts, 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, gtts, coqui, tortoise, qwen` ); } @@ -830,6 +853,10 @@ export async function handleAudioSpeech({ return handleEdgeTtsSpeech(body, clientIp); } + if (providerConfig.format === "gtts") { + return handleGttsSpeech(body); + } + if (providerConfig.format === "xiaomi-mimo-tts") { return handleXiaomiMimoSpeech(providerConfig, body, modelId, token, credentials); } diff --git a/open-sse/handlers/audioTranscription.ts b/open-sse/handlers/audioTranscription.ts index 703004b21d..692b30df35 100644 --- a/open-sse/handlers/audioTranscription.ts +++ b/open-sse/handlers/audioTranscription.ts @@ -531,6 +531,104 @@ async function handleRevAiTranscription(providerConfig, file, modelId, token) { return errorResponse(504, "Rev AI transcription timed out after 120s"); } +/** + * Speechmatics operating point (accuracy tier). Catalog model ids are the + * real Speechmatics `operating_point` values ("standard", "enhanced", + * "melia-1"), so this passes straight through — kept as a named seam in + * case a future catalog id needs remapping. + */ +function speechmaticsOperatingPoint(modelId: string): string { + return modelId; +} + +/** + * Fetch and return the finished Speechmatics transcript once a job reaches + * the "done" state. + */ +async function fetchSpeechmaticsTranscript(jobUrl, authHeaders) { + const transcriptRes = await fetch(`${jobUrl}/transcript?format=txt`, { + headers: { ...authHeaders, Accept: "text/plain" }, + }); + if (!transcriptRes.ok) { + return upstreamErrorResponse(transcriptRes, await transcriptRes.text()); + } + const text = await transcriptRes.text(); + return Response.json({ text: text || "" }, { headers: { ...CORS_HEADERS } }); +} + +function speechmaticsJobErrorMessage(result): string { + const errors = result?.job?.errors; + const first = Array.isArray(errors) ? errors[0] : null; + return first?.message || "Speechmatics transcription failed"; +} + +/** + * Poll a submitted Speechmatics job until it reaches a terminal state + * (max 120s), then fetch its transcript. + */ +async function pollSpeechmaticsJob(jobUrl, authHeaders) { + const maxWait = 120_000; + const start = Date.now(); + + while (Date.now() - start < maxWait) { + await new Promise((r) => setTimeout(r, 2000)); + + const pollRes = await fetch(jobUrl, { headers: authHeaders }); + if (!pollRes.ok) continue; + + const result = await pollRes.json(); + const status = result?.job?.status; + + if (status === "done") { + return fetchSpeechmaticsTranscript(jobUrl, authHeaders); + } + + if (status === "rejected") { + return errorResponse(500, speechmaticsJobErrorMessage(result)); + } + } + + return errorResponse(504, "Speechmatics transcription timed out after 120s"); +} + +/** + * Handle Speechmatics transcription (async batch: submit multipart job → poll → fetch transcript) + * + * Speechmatics batch mode accepts the audio file directly in the job-submission + * multipart body (field "data_file") alongside a JSON "config" field describing + * the requested transcription options. Streaming (real-time WebSocket) mode is + * out of scope for v1 — this handler only implements batch (REST) transcription. + */ +async function handleSpeechmaticsTranscription(providerConfig, file, modelId, token) { + const authHeaders = buildAuthHeaders(providerConfig, token); + const baseUrl = providerConfig.baseUrl.replace(/\/$/, ""); + + // Step 1: submit the job — multipart body with "data_file" (audio) + "config" (JSON) + const config = JSON.stringify({ + type: "transcription", + transcription_config: { operating_point: speechmaticsOperatingPoint(modelId) }, + }); + const { body, contentType } = await buildMultipartBody(file, { config }, "data_file"); + + const submitRes = await fetch(baseUrl, { + method: "POST", + headers: { ...authHeaders, "Content-Type": contentType }, + body, + }); + + if (!submitRes.ok) { + return upstreamErrorResponse(submitRes, await submitRes.text()); + } + + const { id: jobId } = await submitRes.json(); + if (!jobId) { + return errorResponse(502, "Speechmatics did not return a job id"); + } + + // Step 2: poll for completion (max 120s) + return pollSpeechmaticsJob(`${baseUrl}/${jobId}`, authHeaders); +} + /** * Handle audio transcription request * @@ -573,7 +671,7 @@ export async function handleAudioTranscription({ if (!providerConfig) { return errorResponse( 400, - `No transcription provider found for model "${model}". Available: openai, groq, deepgram, assemblyai, nvidia, huggingface, qwen, gladia, rev-ai` + `No transcription provider found for model "${model}". Available: openai, groq, deepgram, assemblyai, nvidia, huggingface, qwen, gladia, rev-ai, speechmatics` ); } @@ -639,6 +737,10 @@ export async function handleAudioTranscription({ return handleRevAiTranscription(providerConfig, file, modelId, token); } + if (providerConfig.format === "speechmatics") { + return handleSpeechmaticsTranscription(providerConfig, file, modelId, token); + } + // Default: OpenAI/Groq/Qwen3-compatible multipart proxy const extraFields: Record = {}; for (const key of [ diff --git a/src/app/api/provider-nodes/route.ts b/src/app/api/provider-nodes/route.ts index 87bb5330b2..e6dbeda5cf 100644 --- a/src/app/api/provider-nodes/route.ts +++ b/src/app/api/provider-nodes/route.ts @@ -19,6 +19,34 @@ const ANTHROPIC_COMPATIBLE_DEFAULTS = { baseUrl: "https://api.anthropic.com/v1", }; +// #6874: VibeProxy (github.com/automazeio/vibeproxy) — local OpenAI-compatible +// gateway. baseUrl has no default (operator-specific host/port); name/prefix/ +// apiType are filled in when the caller omits them. +const VIBEPROXY_OPENAI_DEFAULTS = { + name: "VibeProxy", + prefix: "vibeproxy", + apiType: "chat" as const, +}; + +/** + * Normalize a caller-supplied VibeProxy base URL down to its `/v1` root, + * mirroring `sanitizeAnthropicBaseUrl`/`sanitizeClaudeCodeCompatibleBaseUrl`: + * strip trailing slash + known OpenAI-compatible suffixes, then ensure the + * result ends at `/v1` (append if absent, guard against double `/v1/v1`). + */ +function sanitizeVibeProxyBaseUrl(baseUrl: string) { + let base = (baseUrl || "") + .trim() + .replace(/\/$/, "") + .replace(/\/chat\/completions$/i, "") + .replace(/\/completions$/i, ""); + // Guard against a literal "scheme://v1" authority so we never strip the host itself. + if (base.endsWith("/v1") && !base.endsWith("://v1")) { + return base; + } + return `${base}/v1`; +} + function sanitizeAnthropicBaseUrl(baseUrl: string) { return (baseUrl || "") .trim() @@ -76,16 +104,40 @@ export async function POST(request) { baseUrl, type, compatMode, + preset, chatPath, modelsPath, customHeaders, iconUrl, } = validation.data; + if (preset === "vibeproxy-openai") { + // Schema guarantees baseUrl is non-empty for this preset. + const sanitizedBaseUrl = sanitizeVibeProxyBaseUrl(baseUrl as string); + const baseUrlError = validateProviderNodeBaseUrl(sanitizedBaseUrl); + if (baseUrlError) return baseUrlError; + + const node = await createProviderNode({ + id: `${OPENAI_COMPATIBLE_PREFIX}${VIBEPROXY_OPENAI_DEFAULTS.apiType}-${generateId()}`, + type: "openai-compatible", + prefix: (prefix?.trim() || VIBEPROXY_OPENAI_DEFAULTS.prefix).trim(), + apiType: apiType || VIBEPROXY_OPENAI_DEFAULTS.apiType, + baseUrl: sanitizedBaseUrl, + name: (name?.trim() || VIBEPROXY_OPENAI_DEFAULTS.name).trim(), + chatPath: chatPath || null, + modelsPath: modelsPath || null, + iconUrl: iconUrl?.trim() || null, + customHeaders: customHeaders || null, + }); + return NextResponse.json({ node }, { status: 201 }); + } + // Determine type const nodeType = type || "openai-compatible"; if (nodeType === "openai-compatible") { + const resolvedName = (name || "").trim(); + const resolvedPrefix = (prefix || "").trim(); const resolvedBaseUrl = (baseUrl || OPENAI_COMPATIBLE_DEFAULTS.baseUrl).trim(); const baseUrlError = validateProviderNodeBaseUrl(resolvedBaseUrl); if (baseUrlError) return baseUrlError; @@ -93,10 +145,10 @@ export async function POST(request) { const node = await createProviderNode({ id: `${OPENAI_COMPATIBLE_PREFIX}${apiType}-${generateId()}`, type: "openai-compatible", - prefix: prefix.trim(), + prefix: resolvedPrefix, apiType, baseUrl: resolvedBaseUrl, - name: name.trim(), + name: resolvedName, chatPath: chatPath || null, modelsPath: modelsPath || null, iconUrl: iconUrl?.trim() || null, @@ -124,9 +176,9 @@ export async function POST(request) { ? `${CLAUDE_CODE_COMPATIBLE_PREFIX}${generateId()}` : `${ANTHROPIC_COMPATIBLE_PREFIX}${generateId()}`, type: "anthropic-compatible", - prefix: prefix.trim(), + prefix: (prefix || "").trim(), baseUrl: sanitizedBaseUrl, - name: name.trim(), + name: (name || "").trim(), chatPath: chatPath || null, modelsPath: compatMode === "cc" ? null : modelsPath || null, iconUrl: iconUrl?.trim() || null, diff --git a/src/shared/constants/providers/audio.ts b/src/shared/constants/providers/audio.ts index 3b4376e61e..5a6d9cca1c 100644 --- a/src/shared/constants/providers/audio.ts +++ b/src/shared/constants/providers/audio.ts @@ -87,4 +87,16 @@ export const AUDIO_ONLY_PROVIDERS = { textIcon: "RV", website: "https://www.rev.ai", }, + + speechmatics: { + id: "speechmatics", + alias: "sm", + name: "Speechmatics", + icon: "record_voice_over", + color: "#0A2540", + textIcon: "SM", + website: "https://www.speechmatics.com", + hasFree: true, + freeNote: "Free tier — 8 hours/month, no credit card required. Batch (async) mode only.", + }, }; diff --git a/src/shared/validation/schemas/provider.ts b/src/shared/validation/schemas/provider.ts index 456d30902e..95ee820d55 100644 --- a/src/shared/validation/schemas/provider.ts +++ b/src/shared/validation/schemas/provider.ts @@ -270,8 +270,11 @@ export const removeModelAliasSchema = z.object({ export const createProviderNodeSchema = z .object({ - name: z.string().trim().min(1, "Name is required"), - prefix: z.string().trim().min(1, "Prefix is required"), + // #6874: name/prefix are required in general, but a `preset` (e.g. + // "vibeproxy-openai") supplies both — enforced conditionally below + // instead of unconditionally here. + name: z.string().trim().optional().or(z.literal("")), + prefix: z.string().trim().optional().or(z.literal("")), apiType: z .enum([ "chat", @@ -285,6 +288,10 @@ export const createProviderNodeSchema = z baseUrl: z.string().trim().min(1).optional(), type: z.enum(["openai-compatible", "anthropic-compatible"]).optional(), compatMode: z.enum(["cc"]).optional(), + // #6874: named presets fill in name/prefix/apiType for well-known + // OpenAI-compatible local gateways so the operator only has to paste + // a baseUrl. Currently just VibeProxy (github.com/automazeio/vibeproxy). + preset: z.enum(["vibeproxy-openai"]).optional(), chatPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")), modelsPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")), // #2166: optional operator-supplied remote icon URL for the provider node. Empty @@ -296,6 +303,33 @@ export const createProviderNodeSchema = z }) .superRefine((value, ctx) => { const nodeType = value.type || "openai-compatible"; + if (value.preset === "vibeproxy-openai") { + // Preset supplies name/prefix/apiType — but baseUrl is still mandatory + // (a local proxy's host/port is operator-specific, unlike the generic + // openai-compatible fallback-to-api.openai.com default). + if (!value.baseUrl || !value.baseUrl.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Base URL is required for the VibeProxy preset", + path: ["baseUrl"], + }); + } + return; + } + if (!value.name || !value.name.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Name is required", + path: ["name"], + }); + } + if (!value.prefix || !value.prefix.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Prefix is required", + path: ["prefix"], + }); + } if (nodeType === "openai-compatible" && !value.apiType) { ctx.addIssue({ code: z.ZodIssueCode.custom, diff --git a/tests/unit/gtts-provider.test.ts b/tests/unit/gtts-provider.test.ts new file mode 100644 index 0000000000..337d80ffa1 --- /dev/null +++ b/tests/unit/gtts-provider.test.ts @@ -0,0 +1,187 @@ +// gTTS (Google Translate TTS) audio-tts provider (#6667). +// +// The originally-proposed `/translate_tts` GET endpoint is deprecated; +// this covers the current `batchexecute` RPC mechanism: chunking to the +// 100-char limit, RPC body construction, response parsing, registry lookup, +// and the handler's dispatch + error path. +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + GOOGLE_TTS_MAX_CHARS, + GttsUpstreamError, + buildGttsRpcBody, + chunkGttsText, + normalizeGttsLang, + parseBatchExecuteResponse, + synthesizeGtts, +} from "../../open-sse/executors/gtts.ts"; +import { getSpeechProvider, parseSpeechModel } from "../../open-sse/config/audioRegistry.ts"; +import { handleAudioSpeech } from "../../open-sse/handlers/audioSpeech.ts"; + +// ─── chunkGttsText ────────────────────────────────────────────────────── + +test("chunkGttsText returns a single chunk for short input", () => { + assert.deepEqual(chunkGttsText("hello world"), ["hello world"]); +}); + +test("chunkGttsText returns [] for empty/whitespace-only input", () => { + assert.deepEqual(chunkGttsText(""), []); + assert.deepEqual(chunkGttsText(" "), []); + assert.deepEqual(chunkGttsText(undefined), []); +}); + +test("chunkGttsText splits long input on whitespace within the 100-char limit", () => { + const text = Array.from({ length: 20 }, (_, i) => `word${i}`).join(" "); // > 100 chars + const chunks = chunkGttsText(text); + assert.ok(chunks.length > 1, "must split into multiple chunks"); + for (const chunk of chunks) { + assert.ok(chunk.length <= GOOGLE_TTS_MAX_CHARS, `chunk exceeds limit: "${chunk}"`); + } + // Rejoining chunks must reconstruct the original words (no character loss). + assert.equal(chunks.join(" "), text); +}); + +test("chunkGttsText hard-splits a single word longer than the limit", () => { + const longWord = "a".repeat(250); + const chunks = chunkGttsText(longWord, 100); + assert.equal(chunks.length, 3); + assert.equal(chunks.join(""), longWord); + for (const chunk of chunks) { + assert.ok(chunk.length <= 100); + } +}); + +// ─── normalizeGttsLang ────────────────────────────────────────────────── + +test("normalizeGttsLang accepts simple language codes and defaults to English", () => { + assert.equal(normalizeGttsLang("pt-BR"), "pt-BR"); + assert.equal(normalizeGttsLang("es"), "es"); + assert.equal(normalizeGttsLang(""), "en"); + assert.equal(normalizeGttsLang(undefined), "en"); + assert.equal(normalizeGttsLang(""), "en"); +}); + +// ─── buildGttsRpcBody ─────────────────────────────────────────────────── + +test("buildGttsRpcBody wraps text/lang under the jQ1olc RPC id, urlencoded", () => { + const body = buildGttsRpcBody("hello", "en"); + assert.match(body, /^f\.req=/); + assert.ok(body.endsWith("&")); + + const encoded = body.slice("f.req=".length, -1); + const envelope = JSON.parse(decodeURIComponent(encoded)); + assert.deepEqual(envelope, [[["jQ1olc", JSON.stringify(["hello", "en", true, "null"]), null, "generic"]]]); +}); + +// ─── parseBatchExecuteResponse ────────────────────────────────────────── + +function buildBatchExecuteFixture(base64Audio: string): string { + const inner = JSON.stringify([base64Audio, null, null, null, null, null, []]); + const outer = JSON.stringify([["wrb.fr", "jQ1olc", inner, null, null, null, "generic"]]); + return `)]}'\n\n${outer.length}\n${outer}\n`; +} + +test("parseBatchExecuteResponse extracts the base64 audio payload", () => { + const raw = buildBatchExecuteFixture("aGVsbG8="); + assert.equal(parseBatchExecuteResponse(raw), "aGVsbG8="); +}); + +test("parseBatchExecuteResponse throws GttsUpstreamError when no payload is found", () => { + assert.throws(() => parseBatchExecuteResponse(")]}'\n\nnot json at all"), GttsUpstreamError); +}); + +// ─── synthesizeGtts (network via injectable fetch) ─────────────────────── + +test("synthesizeGtts concatenates decoded audio across multiple chunks", async () => { + const text = Array.from({ length: 20 }, (_, i) => `word${i}`).join(" "); + const expectedChunks = chunkGttsText(text); + assert.ok(expectedChunks.length > 1); + + const calls: string[] = []; + const fetchImpl = async (url: string, init: RequestInit) => { + calls.push(String(init.body)); + return new Response(buildBatchExecuteFixture(Buffer.from("chunk-audio").toString("base64")), { + status: 200, + headers: { "content-type": "text/plain" }, + }); + }; + + const audio = await synthesizeGtts({ text, lang: "en" }, fetchImpl); + assert.equal(calls.length, expectedChunks.length); + assert.equal(audio.toString(), "chunk-audio".repeat(expectedChunks.length)); +}); + +test("synthesizeGtts throws GttsUpstreamError with the upstream status on a non-ok response", async () => { + const fetchImpl = async () => + new Response("rate limited", { status: 429 }); + + await assert.rejects( + () => synthesizeGtts({ text: "hi", lang: "en" }, fetchImpl), + (err: unknown) => err instanceof GttsUpstreamError && err.status === 429 + ); +}); + +// ─── registry lookup ────────────────────────────────────────────────── + +test("gtts is registered as a no-auth speech provider", () => { + const provider = getSpeechProvider("gtts"); + assert.ok(provider); + assert.equal(provider?.authType, "none"); + assert.equal(provider?.format, "gtts"); + + const parsed = parseSpeechModel("gtts/default"); + assert.equal(parsed.provider, "gtts"); + assert.equal(parsed.model, "default"); +}); + +// ─── handleAudioSpeech dispatch ───────────────────────────────────────── + +test("handleAudioSpeech routes gtts requests without requiring credentials", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (_url: string, init: RequestInit) => { + assert.match(String(init.body), /^f\.req=/); + return new Response(buildBatchExecuteFixture(Buffer.from("hi-audio").toString("base64")), { + status: 200, + headers: { "content-type": "text/plain" }, + }); + }) as typeof fetch; + + try { + const providerConfig = getSpeechProvider("gtts"); + const response = await handleAudioSpeech({ + body: { model: "gtts/default", input: "hi there", voice: "en" }, + credentials: null, + resolvedProvider: providerConfig, + resolvedModel: "default", + }); + + assert.equal(response.status, 200); + assert.equal(response.headers.get("content-type"), "audio/mpeg"); + const buf = Buffer.from(await response.arrayBuffer()); + assert.equal(buf.toString(), "hi-audio"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleAudioSpeech surfaces gtts upstream errors without leaking a stack trace", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response("blocked", { status: 429 })) as typeof fetch; + + try { + const providerConfig = getSpeechProvider("gtts"); + const response = await handleAudioSpeech({ + body: { model: "gtts/default", input: "hi there" }, + credentials: null, + resolvedProvider: providerConfig, + resolvedModel: "default", + }); + const payload = (await response.json()) as { error: { message: string } }; + + assert.equal(response.status, 429); + assert.ok(!payload.error.message.includes("at /"), "error body must not leak a stack trace"); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/provider-nodes-vibeproxy-preset.test.ts b/tests/unit/provider-nodes-vibeproxy-preset.test.ts new file mode 100644 index 0000000000..59830e8650 --- /dev/null +++ b/tests/unit/provider-nodes-vibeproxy-preset.test.ts @@ -0,0 +1,135 @@ +// #6874 — VibeProxy (github.com/automazeio/vibeproxy) provider-node preset. +// TDD regression guard for `preset: "vibeproxy-openai"` on +// POST /api/provider-nodes: defaults name/prefix/apiType, still requires an +// explicit baseUrl, and normalizes the caller-supplied baseUrl to its `/v1` +// root the same way the existing Anthropic/Claude-Code-compatible presets do. +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-vibeproxy-preset-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const providerNodesRoute = await import("../../src/app/api/provider-nodes/route.ts"); +const { OPENAI_COMPATIBLE_PREFIX } = await import("../../src/shared/constants/providers.ts"); + +interface ProviderNodeErrorBody { + error: { message: string; details?: { field: string; message: string }[] }; +} + +interface ProviderNodeResponseBody { + node: { + id: string; + type: string; + prefix: string; + name: string; + apiType?: string; + baseUrl: string; + }; +} + +async function resetStorage() { + delete process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS; + delete process.env.OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +function makeRequest(body: unknown) { + return new Request("http://localhost/api/provider-nodes", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + await resetStorage(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("vibeproxy-openai preset creates a node with defaulted name/prefix/apiType", async () => { + const response = await providerNodesRoute.POST( + makeRequest({ + preset: "vibeproxy-openai", + baseUrl: "http://localhost:8317", + }) + ); + const body = (await response.json()) as ProviderNodeResponseBody; + + assert.equal(response.status, 201); + assert.match(body.node.id, new RegExp(`^${OPENAI_COMPATIBLE_PREFIX}chat-`)); + assert.equal(body.node.type, "openai-compatible"); + assert.equal(body.node.name, "VibeProxy"); + assert.equal(body.node.prefix, "vibeproxy"); + assert.equal(body.node.apiType, "chat"); + assert.equal(body.node.baseUrl, "http://localhost:8317/v1"); +}); + +test("vibeproxy-openai preset normalizes a baseUrl with a /chat/completions suffix", async () => { + const response = await providerNodesRoute.POST( + makeRequest({ + preset: "vibeproxy-openai", + baseUrl: "http://localhost:8317/v1/chat/completions", + }) + ); + const body = (await response.json()) as ProviderNodeResponseBody; + + assert.equal(response.status, 201); + assert.equal(body.node.baseUrl, "http://localhost:8317/v1"); +}); + +test("vibeproxy-openai preset appends /v1 when the caller omits it", async () => { + const response = await providerNodesRoute.POST( + makeRequest({ + preset: "vibeproxy-openai", + baseUrl: "http://localhost:9000", + }) + ); + const body = (await response.json()) as ProviderNodeResponseBody; + + assert.equal(response.status, 201); + assert.equal(body.node.baseUrl, "http://localhost:9000/v1"); +}); + +test("vibeproxy-openai preset honors caller-supplied name/prefix instead of the defaults", async () => { + const response = await providerNodesRoute.POST( + makeRequest({ + preset: "vibeproxy-openai", + name: "My VibeProxy", + prefix: "my-vibeproxy", + baseUrl: "http://localhost:8317", + }) + ); + const body = (await response.json()) as ProviderNodeResponseBody; + + assert.equal(response.status, 201); + assert.equal(body.node.name, "My VibeProxy"); + assert.equal(body.node.prefix, "my-vibeproxy"); +}); + +test("vibeproxy-openai preset rejects a missing baseUrl (no silent default)", async () => { + const response = await providerNodesRoute.POST( + makeRequest({ + preset: "vibeproxy-openai", + }) + ); + const body = (await response.json()) as ProviderNodeErrorBody; + + assert.equal(response.status, 400); + assert.equal(body.error.message, "Invalid request"); + assert.match( + body.error.details?.find((d) => d.field === "baseUrl")?.message || "", + /Base URL is required for the VibeProxy preset/ + ); + assert.deepEqual(await providersDb.getProviderNodes(), []); +}); diff --git a/tests/unit/speechmatics-transcription-provider.test.ts b/tests/unit/speechmatics-transcription-provider.test.ts new file mode 100644 index 0000000000..cf61fb09ea --- /dev/null +++ b/tests/unit/speechmatics-transcription-provider.test.ts @@ -0,0 +1,143 @@ +// #6659 — Speechmatics STT provider: async batch workflow (submit multipart +// job → poll → fetch transcript), mirroring the existing AssemblyAI/Rev AI +// adapters. Streaming (WebSocket) mode is explicitly out of scope for v1. +import test from "node:test"; +import assert from "node:assert/strict"; + +const { handleAudioTranscription } = await import("../../open-sse/handlers/audioTranscription.ts"); +const { getTranscriptionProvider } = await import("../../open-sse/config/audioRegistry.ts"); + +function buildFile(contents: string, name: string, type: string) { + return new File([Buffer.from(contents)], name, { type }); +} + +function immediateTimeout(callback: (...args: unknown[]) => void, _ms?: number, ...args: unknown[]) { + if (typeof callback === "function") callback(...args); + return 0; +} + +test("speechmatics registry entry is async, apikey-bearer, batch-only", () => { + const provider = getTranscriptionProvider("speechmatics"); + assert.ok(provider); + assert.equal(provider?.authType, "apikey"); + assert.equal(provider?.authHeader, "bearer"); + assert.equal(provider?.async, true); + assert.equal(provider?.format, "speechmatics"); + assert.ok(provider?.models.some((m) => m.id === "enhanced")); +}); + +test("handleAudioTranscription routes Speechmatics: submit job → poll → fetch transcript", async () => { + const originalFetch = globalThis.fetch; + const originalSetTimeout = globalThis.setTimeout; + const calls: { url: string; method: string }[] = []; + + // @ts-expect-error test double swaps the timer signature intentionally + globalThis.setTimeout = immediateTimeout; + globalThis.fetch = (async (url: string, options: RequestInit = {}) => { + const stringUrl = String(url); + calls.push({ url: stringUrl, method: (options?.method as string) || "GET" }); + + if (stringUrl === "https://asr.api.speechmatics.com/v2/jobs") { + assert.equal(options.method, "POST"); + assert.equal((options.headers as Record).Authorization, "Bearer sm-key"); + return new Response(JSON.stringify({ id: "job-1" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + if (stringUrl === "https://asr.api.speechmatics.com/v2/jobs/job-1") { + return new Response(JSON.stringify({ job: { status: "done" } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + if (stringUrl === "https://asr.api.speechmatics.com/v2/jobs/job-1/transcript?format=txt") { + return new Response("speechmatics result", { + status: 200, + headers: { "content-type": "text/plain" }, + }); + } + + throw new Error(`Unexpected URL: ${stringUrl}`); + }) as typeof fetch; + + try { + const formData = new FormData(); + formData.append("model", "speechmatics/enhanced"); + formData.append("file", buildFile("abc", "clip.wav", "audio/wav")); + + const response = await handleAudioTranscription({ + formData, + credentials: { apiKey: "sm-key" }, + }); + + assert.deepEqual(await response.json(), { text: "speechmatics result" }); + assert.deepEqual( + calls.map((entry) => entry.url), + [ + "https://asr.api.speechmatics.com/v2/jobs", + "https://asr.api.speechmatics.com/v2/jobs/job-1", + "https://asr.api.speechmatics.com/v2/jobs/job-1/transcript?format=txt", + ] + ); + } finally { + globalThis.fetch = originalFetch; + globalThis.setTimeout = originalSetTimeout; + } +}); + +test("handleAudioTranscription returns an error when Speechmatics rejects the job", async () => { + const originalFetch = globalThis.fetch; + const originalSetTimeout = globalThis.setTimeout; + + // @ts-expect-error test double swaps the timer signature intentionally + globalThis.setTimeout = immediateTimeout; + globalThis.fetch = (async (url: string, options: RequestInit = {}) => { + const stringUrl = String(url); + if (stringUrl === "https://asr.api.speechmatics.com/v2/jobs") { + return new Response(JSON.stringify({ id: "job-2" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (stringUrl === "https://asr.api.speechmatics.com/v2/jobs/job-2") { + return new Response( + JSON.stringify({ job: { status: "rejected", errors: [{ message: "bad audio" }] } }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + throw new Error(`Unexpected URL: ${stringUrl} (${options.method})`); + }) as typeof fetch; + + try { + const formData = new FormData(); + formData.append("model", "speechmatics/enhanced"); + formData.append("file", buildFile("abc", "clip.wav", "audio/wav")); + + const response = await handleAudioTranscription({ + formData, + credentials: { apiKey: "sm-key" }, + }); + const payload = (await response.json()) as { error: { message: string } }; + + assert.equal(response.status, 500); + assert.equal(payload.error.message, "bad audio"); + } finally { + globalThis.fetch = originalFetch; + globalThis.setTimeout = originalSetTimeout; + } +}); + +test("handleAudioTranscription requires credentials for Speechmatics", async () => { + const formData = new FormData(); + formData.append("model", "speechmatics/enhanced"); + formData.append("file", buildFile("abc", "clip.wav", "audio/wav")); + + const response = await handleAudioTranscription({ formData, credentials: null }); + const payload = (await response.json()) as { error: { message: string } }; + + assert.equal(response.status, 401); + assert.match(payload.error.message, /speechmatics/); +});