From a6d19cc4ba09fc1242ba3b7100daa2fbab2e36ad Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:31:31 -0300 Subject: [PATCH] feat(providers): add Rev AI speech-to-text provider (#6655) (#7596) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registers Rev AI as a 13th async-job STT provider, mirroring the AssemblyAI/Kie.ai upload -> submit -> poll pattern already used by the audio transcription handler: - audioRegistry.ts: new "rev-ai" entry (bearer auth, async: true, format: "rev-ai") with machine/low_cost/fusion transcriber models. - audioTranscription.ts: handleRevAiTranscription() submits the job with the media file inline in the multipart body (field "media"), polls GET /jobs/{id} until "transcribed"/"failed", then fetches the plain-text transcript. buildMultipartBody() gained an optional fileFieldName param (default "file") so Rev AI's "media" field name doesn't require a bespoke multipart builder. Errors route through the existing upstreamErrorResponse()/errorResponse() helpers. - providers/audio.ts: catalog entry (id/alias/name/icon/color/website) for the dashboard connection UI. - validation/audioMiscProviders.ts + validation.ts: validateRevAiProvider wired into the provider "Test Connection" dispatcher. Streaming (WebSocket) STT is scoped as a follow-up per the analyzed plan — no existing precedent to extend, needs its own design pass. Closes #6655. --- .../features/6655-revai-stt-provider.md | 1 + open-sse/config/audioRegistry.ts | 14 ++ open-sse/handlers/audioTranscription.ts | 70 ++++++++- src/lib/providers/validation.ts | 2 + .../validation/audioMiscProviders.ts | 16 ++ src/shared/constants/providers/audio.ts | 9 ++ .../unit/audio-transcription-handler.test.ts | 137 ++++++++++++++++++ 7 files changed, 246 insertions(+), 3 deletions(-) create mode 100644 changelog.d/features/6655-revai-stt-provider.md diff --git a/changelog.d/features/6655-revai-stt-provider.md b/changelog.d/features/6655-revai-stt-provider.md new file mode 100644 index 0000000000..5677687649 --- /dev/null +++ b/changelog.d/features/6655-revai-stt-provider.md @@ -0,0 +1 @@ +- feat(providers): add Rev AI speech-to-text provider with async job upload/poll/transcript flow (#6655) diff --git a/open-sse/config/audioRegistry.ts b/open-sse/config/audioRegistry.ts index 144d0f563f..5dca0e6ec4 100644 --- a/open-sse/config/audioRegistry.ts +++ b/open-sse/config/audioRegistry.ts @@ -159,6 +159,20 @@ export const AUDIO_TRANSCRIPTION_PROVIDERS: Record = { { id: "elevenlabs/audio-isolation", name: "ElevenLabs Audio Isolation" }, ], }, + + "rev-ai": { + id: "rev-ai", + baseUrl: "https://api.rev.ai/speechtotext/v1", + authType: "apikey", + authHeader: "bearer", + async: true, + format: "rev-ai", + models: [ + { id: "machine", name: "Reverb ASR" }, + { id: "low_cost", name: "Low-Cost ASR" }, + { id: "fusion", name: "Fusion ASR" }, + ], + }, }; /** diff --git a/open-sse/handlers/audioTranscription.ts b/open-sse/handlers/audioTranscription.ts index af443427c8..32f8d2c3ce 100644 --- a/open-sse/handlers/audioTranscription.ts +++ b/open-sse/handlers/audioTranscription.ts @@ -72,7 +72,8 @@ function getUploadedFileName(file: Blob & { name?: unknown }): string { export async function buildMultipartBody( file: Blob & { name?: unknown }, - fields: Record + fields: Record, + fileFieldName = "file" ): Promise<{ body: Uint8Array; contentType: string }> { const boundary = "----OmniRouteAudioBoundary" + Date.now().toString(36); const parts: Uint8Array[] = []; @@ -92,7 +93,7 @@ export async function buildMultipartBody( const fileBytes = new Uint8Array(await file.arrayBuffer()); parts.push( encoder.encode( - `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\nContent-Type: ${file.type || "application/octet-stream"}\r\n\r\n` + `--${boundary}\r\nContent-Disposition: form-data; name="${fileFieldName}"; filename="${fileName}"\r\nContent-Type: ${file.type || "application/octet-stream"}\r\n\r\n` ) ); parts.push(fileBytes); @@ -409,6 +410,65 @@ async function pollKieTranscriptionResult(baseUrl, modelId, taskId, token) { return errorResponse(504, "Kie transcription generation timed out or failed"); } +/** + * Handle Rev AI transcription (async: submit job with media upload → poll → fetch transcript) + * + * Rev AI accepts the audio file directly in the job-submission multipart body + * (field "media"), avoiding AssemblyAI's separate upload step. Once the job + * reaches a terminal state we fetch the plain-text transcript. + */ +async function handleRevAiTranscription(providerConfig, file, modelId, token) { + const authHeaders = buildAuthHeaders(providerConfig, token); + const baseUrl = providerConfig.baseUrl.replace(/\/$/, ""); + + // Step 1: submit the job — multipart body with "media" (file) + "options" (JSON) + const options = JSON.stringify({ transcriber: modelId }); + const { body, contentType } = await buildMultipartBody(file, { options }, "media"); + + const submitRes = await fetch(`${baseUrl}/jobs`, { + method: "POST", + headers: { ...authHeaders, "Content-Type": contentType }, + body, + }); + + if (!submitRes.ok) { + return upstreamErrorResponse(submitRes, await submitRes.text()); + } + + const { id: jobId } = await submitRes.json(); + + // Step 2: poll for completion (max 120s) + const jobUrl = `${baseUrl}/jobs/${jobId}`; + 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(); + + if (result.status === "transcribed") { + const transcriptRes = await fetch(`${jobUrl}/transcript`, { + 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 } }); + } + + if (result.status === "failed") { + return errorResponse(500, result.failure_detail || "Rev AI transcription failed"); + } + } + + return errorResponse(504, "Rev AI transcription timed out after 120s"); +} + /** * Handle audio transcription request * @@ -451,7 +511,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` + `No transcription provider found for model "${model}". Available: openai, groq, deepgram, assemblyai, nvidia, huggingface, qwen, rev-ai` ); } @@ -509,6 +569,10 @@ export async function handleAudioTranscription({ return handleKieAudioTranscription(providerConfig, file, modelId, token); } + if (providerConfig.format === "rev-ai") { + return handleRevAiTranscription(providerConfig, file, modelId, token); + } + // Default: OpenAI/Groq/Qwen3-compatible multipart proxy const extraFields: Record = {}; for (const key of [ diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index aeda6d7545..b0a82f841f 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -68,6 +68,7 @@ import { import { validateDeepgramProvider, validateAssemblyAIProvider, + validateRevAiProvider, validateElevenLabsProvider, validateInworldProvider, validateKieProvider, @@ -478,6 +479,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi bytez: validateBytezProvider, deepgram: validateDeepgramProvider, assemblyai: validateAssemblyAIProvider, + "rev-ai": validateRevAiProvider, "fal-ai": ({ apiKey, providerSpecificData }: any) => validateImageProviderApiKey({ provider: "fal-ai", apiKey, providerSpecificData }), "stability-ai": ({ apiKey, providerSpecificData }: any) => diff --git a/src/lib/providers/validation/audioMiscProviders.ts b/src/lib/providers/validation/audioMiscProviders.ts index 2672fce0c1..052c1bcbf6 100644 --- a/src/lib/providers/validation/audioMiscProviders.ts +++ b/src/lib/providers/validation/audioMiscProviders.ts @@ -55,6 +55,22 @@ export async function validateAssemblyAIProvider({ apiKey, providerSpecificData } } +export async function validateRevAiProvider({ apiKey, providerSpecificData = {} }: any) { + try { + const response = await validationRead("https://api.rev.ai/speechtotext/v1/jobs?limit=1", { + method: "GET", + headers: buildBearerHeaders(apiKey, providerSpecificData), + }); + if (response.ok) return { valid: true, error: null }; + if (response.status === 401 || response.status === 403) { + return { valid: false, error: "Invalid API key" }; + } + return { valid: false, error: `Validation failed: ${response.status}` }; + } catch (error: any) { + return toValidationErrorResult(error); + } +} + export async function validateElevenLabsProvider({ apiKey, providerSpecificData = {} }: any) { try { // Lightweight auth check endpoint diff --git a/src/shared/constants/providers/audio.ts b/src/shared/constants/providers/audio.ts index 9db4591524..9a6ec87062 100644 --- a/src/shared/constants/providers/audio.ts +++ b/src/shared/constants/providers/audio.ts @@ -68,4 +68,13 @@ export const AUDIO_ONLY_PROVIDERS = { authHint: "Use AWS Secret Access Key as API key; set providerSpecificData.accessKeyId and optional region.", }, + "rev-ai": { + id: "rev-ai", + alias: "revai", + name: "Rev AI", + icon: "record_voice_over", + color: "#FF5C35", + textIcon: "RV", + website: "https://www.rev.ai", + }, }; diff --git a/tests/unit/audio-transcription-handler.test.ts b/tests/unit/audio-transcription-handler.test.ts index 8bde9d5426..a21491ac5b 100644 --- a/tests/unit/audio-transcription-handler.test.ts +++ b/tests/unit/audio-transcription-handler.test.ts @@ -359,6 +359,143 @@ test("handleAudioTranscription returns an error when AssemblyAI reports a termin } }); +test("handleAudioTranscription routes Rev AI uploads and polls until transcribed", async () => { + const originalFetch = globalThis.fetch; + const originalSetTimeout = globalThis.setTimeout; + const calls = []; + + globalThis.setTimeout = immediateTimeout; + globalThis.fetch = async (url, options = {}) => { + const stringUrl = String(url); + calls.push({ url: stringUrl, method: options?.method || "GET" }); + + if (stringUrl === "https://api.rev.ai/speechtotext/v1/jobs") { + assert.equal(options.method, "POST"); + assert.ok(options.body instanceof Uint8Array); + const bodyText = new TextDecoder().decode(options.body); + assert.ok(bodyText.includes('name="media"')); + assert.ok(bodyText.includes('name="options"')); + assert.ok(bodyText.includes('"transcriber":"machine"')); + return new Response(JSON.stringify({ id: "job-1", status: "in_progress" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + if (stringUrl === "https://api.rev.ai/speechtotext/v1/jobs/job-1") { + return new Response(JSON.stringify({ id: "job-1", status: "transcribed" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + if (stringUrl === "https://api.rev.ai/speechtotext/v1/jobs/job-1/transcript") { + assert.equal(options.headers.Accept, "text/plain"); + return new Response("hello from rev ai", { + status: 200, + headers: { "content-type": "text/plain" }, + }); + } + + throw new Error(`Unexpected URL: ${stringUrl}`); + }; + + try { + const formData = new FormData(); + formData.append("model", "rev-ai/machine"); + formData.append("file", buildFile("abc", "clip.wav", "audio/wav")); + + const response = await handleAudioTranscription({ + formData, + credentials: { apiKey: "revai-key" }, + }); + + assert.deepEqual(await response.json(), { text: "hello from rev ai" }); + assert.deepEqual( + calls.map((entry) => entry.url), + [ + "https://api.rev.ai/speechtotext/v1/jobs", + "https://api.rev.ai/speechtotext/v1/jobs/job-1", + "https://api.rev.ai/speechtotext/v1/jobs/job-1/transcript", + ] + ); + } finally { + globalThis.fetch = originalFetch; + globalThis.setTimeout = originalSetTimeout; + } +}); + +test("handleAudioTranscription returns an error when Rev AI reports a failed job", async () => { + const originalFetch = globalThis.fetch; + const originalSetTimeout = globalThis.setTimeout; + + globalThis.setTimeout = immediateTimeout; + globalThis.fetch = async (url) => { + const stringUrl = String(url); + + if (stringUrl === "https://api.rev.ai/speechtotext/v1/jobs") { + return new Response(JSON.stringify({ id: "job-2", status: "in_progress" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + if (stringUrl === "https://api.rev.ai/speechtotext/v1/jobs/job-2") { + return new Response( + JSON.stringify({ id: "job-2", status: "failed", failure_detail: "corrupt audio" }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + + throw new Error(`Unexpected URL: ${stringUrl}`); + }; + + try { + const formData = new FormData(); + formData.append("model", "rev-ai/machine"); + formData.append("file", buildFile("abc", "clip.wav", "audio/wav")); + + const response = await handleAudioTranscription({ + formData, + credentials: { apiKey: "revai-key" }, + }); + const payload = (await response.json()) as { error: { message: string } }; + + assert.equal(response.status, 500); + assert.equal(payload.error.message, "corrupt audio"); + } finally { + globalThis.fetch = originalFetch; + globalThis.setTimeout = originalSetTimeout; + } +}); + +test("handleAudioTranscription surfaces Rev AI job-submission errors without polling", async () => { + const originalFetch = globalThis.fetch; + + globalThis.fetch = async () => + new Response(JSON.stringify({ error: "Invalid access token" }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + + try { + const formData = new FormData(); + formData.append("model", "rev-ai/machine"); + formData.append("file", buildFile("abc", "clip.wav", "audio/wav")); + + const response = await handleAudioTranscription({ + formData, + credentials: { apiKey: "bad-key" }, + }); + const payload = (await response.json()) as { error: { message: string } }; + + assert.equal(response.status, 401); + assert.equal(payload.error.message, "Invalid access token"); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("handleAudioTranscription routes HuggingFace providers with raw audio uploads", async () => { const originalFetch = globalThis.fetch; let capturedUrl;