mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 21:02:12 +03:00
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.
This commit is contained in:
committed by
GitHub
parent
1e945df6af
commit
a6d19cc4ba
1
changelog.d/features/6655-revai-stt-provider.md
Normal file
1
changelog.d/features/6655-revai-stt-provider.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(providers): add Rev AI speech-to-text provider with async job upload/poll/transcript flow (#6655)
|
||||
@@ -159,6 +159,20 @@ export const AUDIO_TRANSCRIPTION_PROVIDERS: Record<string, AudioProvider> = {
|
||||
{ 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" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -72,7 +72,8 @@ function getUploadedFileName(file: Blob & { name?: unknown }): string {
|
||||
|
||||
export async function buildMultipartBody(
|
||||
file: Blob & { name?: unknown },
|
||||
fields: Record<string, string>
|
||||
fields: Record<string, string>,
|
||||
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<string, string> = {};
|
||||
for (const key of [
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user